kopia lustrzana https://github.com/ryukoposting/Signal-Android
Add helper text when dragging filter at a low velocity.
rodzic
5cb3e1cd02
commit
14503b952a
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
Ładowanie…
Reference in New Issue