Add helper text when dragging filter at a low velocity.

main
Alex Hart 2023-01-03 10:31:31 -04:00 zatwierdzone przez GitHub
rodzic 5cb3e1cd02
commit 14503b952a
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 4AEE18F83AFDEB23
8 zmienionych plików z 233 dodań i 2 usunięć

Wyświetl plik

@ -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

Wyświetl plik

@ -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.
*/

Wyświetl plik

@ -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<Float>(capacity)
private val durationBuffer = RingBuffer<Duration>(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
}
}

Wyświetl plik

@ -0,0 +1,21 @@
package org.thoughtcrime.securesms.conversationlist.chatfilter
import androidx.collection.CircularArray
class RingBuffer<T>(@androidx.annotation.IntRange(from = 0) private val capacity: Int) {
private val buffer = CircularArray<T>(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)
}

Wyświetl plik

@ -36,4 +36,14 @@
app:closeIconSize="18dp"
app:ensureMinTouchTargetSize="false" />
<TextView
android:id="@+id/help_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal|bottom"
android:alpha="0"
android:text="@string/ChatFilter__pull_to_filter"
android:textAppearance="@style/Signal.Text.BodyMedium"
android:textColor="@color/signal_colorSecondary" />
</merge>

Wyświetl plik

@ -5467,5 +5467,7 @@
<!-- ChatFilter -->
<!-- Displayed in a pill at the top of the chat list when it is filtered by unread messages -->
<string name="ChatFilter__filtered_by_unread">Filtered by unread</string>
<!-- Displayed underneath the filter circle at the top of the chat list when the user pulls at a very low velocity -->
<string name="ChatFilter__pull_to_filter">Pull to filter</string>
</resources>

Wyświetl plik

@ -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)
}
}

Wyświetl plik

@ -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<Int>(-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<Int>(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<Int>(10)
testSubject[0]
}
@Test
fun `Given some added elements, when I get, then I expect the element`() {
val testSubject = RingBuffer<Int>(10)
val expected = 1
testSubject.add(expected)
val actual = testSubject[0]
assertEquals("Expected get to return $expected", expected, actual)
}
}