diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/chatfilter/ConversationListFilterPullView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/chatfilter/ConversationListFilterPullView.kt index 958c1839d..0f4b91271 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/chatfilter/ConversationListFilterPullView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/chatfilter/ConversationListFilterPullView.kt @@ -1,15 +1,18 @@ package org.thoughtcrime.securesms.conversationlist.chatfilter import android.animation.Animator -import android.animation.FloatEvaluator import android.animation.ObjectAnimator import android.content.Context import android.util.AttributeSet import android.widget.FrameLayout import androidx.core.animation.doOnEnd import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.animation.AnimationCompleteListener import org.thoughtcrime.securesms.databinding.ConversationListFilterPullViewBinding import org.thoughtcrime.securesms.util.VibrateUtil +import kotlin.math.max +import kotlin.math.min +import kotlin.time.Duration.Companion.milliseconds /** * Encapsulates the push / pull latch for enabling and disabling @@ -24,7 +27,9 @@ class ConversationListFilterPullView @JvmOverloads constructor( ) : FrameLayout(context, attrs) { companion object { - private val EVAL = FloatEvaluator() + private const val ANIMATE_HELP_TEXT_VELOCITY_THRESHOLD = 1f + private const val ANIMATE_HELP_TEXT_THRESHOLD = 30 + private const val ANIMATE_HELP_TEXT_START_FRACTION = 0.35f } private val binding: ConversationListFilterPullViewBinding @@ -42,6 +47,9 @@ class ConversationListFilterPullView @JvmOverloads constructor( } private var pillAnimator: Animator? = null + private val velocityTracker = ProgressVelocityTracker(5) + private var animateHelpText = 0 + private var helpTextStartFraction = 0.35f fun onUserDrag(progress: Float) { binding.filterCircle.textFieldMetrics = Pair(binding.filterText.width, binding.filterText.height) @@ -52,11 +60,33 @@ class ConversationListFilterPullView @JvmOverloads constructor( } else if (state == FilterPullState.CLOSED && progress >= 1f) { setState(FilterPullState.OPEN_APEX) vibrate() + resetHelpText() } else if (state == FilterPullState.OPEN && progress >= 1f) { setState(FilterPullState.CLOSE_APEX) vibrate() } + if (state == FilterPullState.CLOSED && animateHelpText < ANIMATE_HELP_TEXT_THRESHOLD) { + velocityTracker.submitProgress(progress, System.currentTimeMillis().milliseconds) + val velocity = velocityTracker.calculateVelocity() + animateHelpText = if (velocity > 0f && velocity < ANIMATE_HELP_TEXT_VELOCITY_THRESHOLD) { + min(Int.MAX_VALUE, animateHelpText + 1) + } else { + max(0, animateHelpText - 1) + } + + if (animateHelpText >= ANIMATE_HELP_TEXT_THRESHOLD) { + helpTextStartFraction = max(progress, ANIMATE_HELP_TEXT_START_FRACTION) + } + } + + if (animateHelpText >= ANIMATE_HELP_TEXT_THRESHOLD) { + binding.helpText.visibility = VISIBLE + } + + binding.helpText.alpha = max(0f, FilterLerp.getHelpTextAlphaLerp(progress, helpTextStartFraction)) + binding.helpText.translationY = FilterLerp.getPillLerp(progress) + if (state == FilterPullState.OPEN || state == FilterPullState.OPEN_APEX || state == FilterPullState.CLOSE_APEX || state == FilterPullState.CLOSING) { binding.filterText.translationY = FilterLerp.getPillLerp(progress) } else { @@ -96,6 +126,17 @@ class ConversationListFilterPullView @JvmOverloads constructor( animatePillOut() } + private fun resetHelpText() { + velocityTracker.clear() + animateHelpText = 0 + helpTextStartFraction = ANIMATE_HELP_TEXT_START_FRACTION + binding.helpText.animate().alpha(0f).setListener(object : AnimationCompleteListener() { + override fun onAnimationEnd(animation: Animator?) { + binding.helpText.visibility = INVISIBLE + } + }) + } + private fun animatePillIn() { binding.filterText.visibility = VISIBLE binding.filterText.alpha = 0f diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/chatfilter/FilterLerp.kt b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/chatfilter/FilterLerp.kt index 752f46ffd..dfa9b1d4e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/chatfilter/FilterLerp.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/chatfilter/FilterLerp.kt @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.conversationlist.chatfilter import android.animation.FloatEvaluator +import androidx.annotation.FloatRange import org.signal.core.util.dp /** @@ -36,6 +37,15 @@ object FilterLerp { Point(1f, FILTER_APEX * 0.55f) ) + private fun helpTextAlphaLerp(@FloatRange(from = 0.0, to = 1.0) startFraction: Float) = getFn( + Point(startFraction, 0f), + Point(1f, 1f) + ) + + fun getHelpTextAlphaLerp(fraction: Float, startFraction: Float): Float { + return getLerp(fraction, helpTextAlphaLerp(startFraction)) + } + /** * Get the LERP for the "Filter enabled" pill. */ diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/chatfilter/ProgressVelocityTracker.kt b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/chatfilter/ProgressVelocityTracker.kt new file mode 100644 index 000000000..e5b189f46 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/chatfilter/ProgressVelocityTracker.kt @@ -0,0 +1,49 @@ +package org.thoughtcrime.securesms.conversationlist.chatfilter + +import androidx.annotation.FloatRange +import org.whispersystems.signalservice.api.util.Preconditions +import kotlin.time.Duration + +/** + * Velocity tracker based off % progress. + * Units are thus in %/s + * + * This class only supports a single axial value. + */ +class ProgressVelocityTracker(@androidx.annotation.IntRange(from = 0) capacity: Int) { + + private val progressBuffer = RingBuffer(capacity) + private val durationBuffer = RingBuffer(capacity) + + fun submitProgress(@FloatRange(from = 0.0, to = 1.0) progress: Float, duration: Duration) { + progressBuffer.add(progress) + durationBuffer.add(duration) + } + + fun clear() { + progressBuffer.clear() + durationBuffer.clear() + } + + /** + * Calculates the average velocity. The units are %/s + */ + fun calculateVelocity(): Float { + Preconditions.checkState(progressBuffer.size() == durationBuffer.size()) + + if (progressBuffer.size() < 2) { + return 0f + } + + var progressDelta: Float + var timeDelta: Duration + + val percentPerMillisecond = (0 until progressBuffer.size()).windowed(2).map { (indexA, indexB) -> + progressDelta = progressBuffer[indexB] - progressBuffer[indexA] + timeDelta = durationBuffer[indexB] - durationBuffer[indexA] + progressDelta / timeDelta.inWholeMilliseconds + }.sum() / (progressBuffer.size() - 1) + + return percentPerMillisecond * 1000 + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/chatfilter/RingBuffer.kt b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/chatfilter/RingBuffer.kt new file mode 100644 index 000000000..592f65c25 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/chatfilter/RingBuffer.kt @@ -0,0 +1,21 @@ +package org.thoughtcrime.securesms.conversationlist.chatfilter + +import androidx.collection.CircularArray + +class RingBuffer(@androidx.annotation.IntRange(from = 0) private val capacity: Int) { + private val buffer = CircularArray(capacity) + + fun add(t: T) { + if (size() == capacity) { + buffer.popFirst() + } + + buffer.addLast(t) + } + + fun size() = buffer.size() + + fun clear() = buffer.clear() + + operator fun get(index: Int): T = buffer.get(index) +} diff --git a/app/src/main/res/layout/conversation_list_filter_pull_view.xml b/app/src/main/res/layout/conversation_list_filter_pull_view.xml index 851d74591..110c52b43 100644 --- a/app/src/main/res/layout/conversation_list_filter_pull_view.xml +++ b/app/src/main/res/layout/conversation_list_filter_pull_view.xml @@ -36,4 +36,14 @@ app:closeIconSize="18dp" app:ensureMinTouchTargetSize="false" /> + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 51a2c35ca..bdf059a0c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -5467,5 +5467,7 @@ Filtered by unread + + Pull to filter diff --git a/app/src/test/java/org/thoughtcrime/securesms/conversationlist/chatfilter/ProgressVelocityTrackerTest.kt b/app/src/test/java/org/thoughtcrime/securesms/conversationlist/chatfilter/ProgressVelocityTrackerTest.kt new file mode 100644 index 000000000..67d735d0c --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/conversationlist/chatfilter/ProgressVelocityTrackerTest.kt @@ -0,0 +1,57 @@ +package org.thoughtcrime.securesms.conversationlist.chatfilter + +import org.junit.Assert.assertEquals +import org.junit.Test +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +class ProgressVelocityTrackerTest { + + @Test + fun `When I calculate velocity, then I expect 0f`() { + val testSubject = ProgressVelocityTracker(3) + val actual = testSubject.calculateVelocity() + assertEquals("Velocity of an empty tracker should be 0f", 0f, actual) + } + + @Test + fun `Given a single entry, when I calculate velocity, then I expect 0f`() { + val testSubject = ProgressVelocityTracker(3) + testSubject.submitProgress(0f, 0.milliseconds) + val actual = testSubject.calculateVelocity() + assertEquals("Velocity of a tracker with a single element should be 0f", 0f, actual) + } + + @Test + fun `Given 0f to 1f in 1 second, when I calculate velocity, then I expect a rate of 100 percent per second`() { + val testSubject = ProgressVelocityTracker(3) + testSubject.submitProgress(0f, 0.milliseconds) + testSubject.submitProgress(1f, 1.seconds) + val actual = testSubject.calculateVelocity() + assertEquals("If we complete the progress in 1 second, then we should have a rate of 1 per thousand milliseconds", 1f, actual) + } + + @Test + fun `Given 5 entries, when I calculate velocity, then I expect a rate based off the last 3 entries`() { + val testSubject = ProgressVelocityTracker(3) + val entries = listOf( + 0.0f to 0.seconds, + 0.1f to 10.milliseconds, + 0.2f to 20.milliseconds, + 0.3f to 40.milliseconds, + 0.4f to 80.milliseconds + ) + + entries.forEach { (progress, duration) -> testSubject.submitProgress(progress, duration) } + + val velocityA = testSubject.calculateVelocity() + + testSubject.clear() + + entries.drop(2).forEach { (progress, duration) -> testSubject.submitProgress(progress, duration) } + + val velocityB = testSubject.calculateVelocity() + + assertEquals("Expected the velocities to match.", velocityA, velocityB) + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/conversationlist/chatfilter/RingBufferTest.kt b/app/src/test/java/org/thoughtcrime/securesms/conversationlist/chatfilter/RingBufferTest.kt new file mode 100644 index 000000000..7d76f6142 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/conversationlist/chatfilter/RingBufferTest.kt @@ -0,0 +1,41 @@ +package org.thoughtcrime.securesms.conversationlist.chatfilter + +import org.junit.Assert.assertEquals +import org.junit.Test + +class RingBufferTest { + + @Test(expected = IllegalArgumentException::class) + fun `Given a negative capacity, when I call the constructor, then I expect an IllegalArgumentException`() { + RingBuffer(-1) + } + + @Test + fun `Given I enqueue more items than my capacity, when I getSize, then I expect my initial capacity`() { + val capacity = 10 + val testSubject = RingBuffer(capacity) + + (1..(capacity * 2)).forEach { + testSubject.add(it) + } + + assertEquals("Capacity should never exceed $capacity items.", capacity, testSubject.size()) + assertEquals("First item should be 10", 11, testSubject[0]) + assertEquals("Last item should be 20", 20, testSubject[testSubject.size() - 1]) + } + + @Test(expected = ArrayIndexOutOfBoundsException::class) + fun `when I get, then I expect an ArrayIndexOutOfBoundsException`() { + val testSubject = RingBuffer(10) + testSubject[0] + } + + @Test + fun `Given some added elements, when I get, then I expect the element`() { + val testSubject = RingBuffer(10) + val expected = 1 + testSubject.add(expected) + val actual = testSubject[0] + assertEquals("Expected get to return $expected", expected, actual) + } +}