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