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 package org.thoughtcrime.securesms.conversationlist.chatfilter
import android.animation.Animator import android.animation.Animator
import android.animation.FloatEvaluator
import android.animation.ObjectAnimator import android.animation.ObjectAnimator
import android.content.Context import android.content.Context
import android.util.AttributeSet import android.util.AttributeSet
import android.widget.FrameLayout import android.widget.FrameLayout
import androidx.core.animation.doOnEnd import androidx.core.animation.doOnEnd
import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.animation.AnimationCompleteListener
import org.thoughtcrime.securesms.databinding.ConversationListFilterPullViewBinding import org.thoughtcrime.securesms.databinding.ConversationListFilterPullViewBinding
import org.thoughtcrime.securesms.util.VibrateUtil 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 * Encapsulates the push / pull latch for enabling and disabling
@ -24,7 +27,9 @@ class ConversationListFilterPullView @JvmOverloads constructor(
) : FrameLayout(context, attrs) { ) : FrameLayout(context, attrs) {
companion object { 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 private val binding: ConversationListFilterPullViewBinding
@ -42,6 +47,9 @@ class ConversationListFilterPullView @JvmOverloads constructor(
} }
private var pillAnimator: Animator? = null private var pillAnimator: Animator? = null
private val velocityTracker = ProgressVelocityTracker(5)
private var animateHelpText = 0
private var helpTextStartFraction = 0.35f
fun onUserDrag(progress: Float) { fun onUserDrag(progress: Float) {
binding.filterCircle.textFieldMetrics = Pair(binding.filterText.width, binding.filterText.height) 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) { } else if (state == FilterPullState.CLOSED && progress >= 1f) {
setState(FilterPullState.OPEN_APEX) setState(FilterPullState.OPEN_APEX)
vibrate() vibrate()
resetHelpText()
} else if (state == FilterPullState.OPEN && progress >= 1f) { } else if (state == FilterPullState.OPEN && progress >= 1f) {
setState(FilterPullState.CLOSE_APEX) setState(FilterPullState.CLOSE_APEX)
vibrate() 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) { if (state == FilterPullState.OPEN || state == FilterPullState.OPEN_APEX || state == FilterPullState.CLOSE_APEX || state == FilterPullState.CLOSING) {
binding.filterText.translationY = FilterLerp.getPillLerp(progress) binding.filterText.translationY = FilterLerp.getPillLerp(progress)
} else { } else {
@ -96,6 +126,17 @@ class ConversationListFilterPullView @JvmOverloads constructor(
animatePillOut() 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() { private fun animatePillIn() {
binding.filterText.visibility = VISIBLE binding.filterText.visibility = VISIBLE
binding.filterText.alpha = 0f binding.filterText.alpha = 0f

Wyświetl plik

@ -1,6 +1,7 @@
package org.thoughtcrime.securesms.conversationlist.chatfilter package org.thoughtcrime.securesms.conversationlist.chatfilter
import android.animation.FloatEvaluator import android.animation.FloatEvaluator
import androidx.annotation.FloatRange
import org.signal.core.util.dp import org.signal.core.util.dp
/** /**
@ -36,6 +37,15 @@ object FilterLerp {
Point(1f, FILTER_APEX * 0.55f) 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. * 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:closeIconSize="18dp"
app:ensureMinTouchTargetSize="false" /> 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> </merge>

Wyświetl plik

@ -5467,5 +5467,7 @@
<!-- ChatFilter --> <!-- ChatFilter -->
<!-- Displayed in a pill at the top of the chat list when it is filtered by unread messages --> <!-- 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> <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> </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)
}
}