kopia lustrzana https://github.com/ryukoposting/Signal-Android
285 wiersze
9.7 KiB
Kotlin
285 wiersze
9.7 KiB
Kotlin
package org.thoughtcrime.securesms.conversationlist.chatfilter
|
|
|
|
import android.animation.Animator
|
|
import android.animation.ObjectAnimator
|
|
import android.animation.ValueAnimator
|
|
import android.content.Context
|
|
import android.content.res.ColorStateList
|
|
import android.os.Bundle
|
|
import android.os.Parcelable
|
|
import android.util.AttributeSet
|
|
import android.widget.FrameLayout
|
|
import androidx.core.animation.doOnEnd
|
|
import androidx.core.content.ContextCompat
|
|
import androidx.core.os.bundleOf
|
|
import androidx.core.view.doOnNextLayout
|
|
import com.google.android.material.animation.ArgbEvaluatorCompat
|
|
import org.thoughtcrime.securesms.R
|
|
import org.thoughtcrime.securesms.animation.AnimationCompleteListener
|
|
import org.thoughtcrime.securesms.databinding.ConversationListFilterPullViewBinding
|
|
import org.thoughtcrime.securesms.util.VibrateUtil
|
|
import org.thoughtcrime.securesms.util.doOnEachLayout
|
|
import kotlin.math.max
|
|
import kotlin.math.min
|
|
import kotlin.time.Duration.Companion.milliseconds
|
|
|
|
/**
|
|
* Encapsulates the push / pull latch for enabling and disabling
|
|
* filters into a convenient view.
|
|
*
|
|
* The view should retain a height of 52dp when it is released by the user, which
|
|
* maps to a progress of 52%
|
|
*/
|
|
class ConversationListFilterPullView @JvmOverloads constructor(
|
|
context: Context,
|
|
attrs: AttributeSet? = null
|
|
) : FrameLayout(context, attrs) {
|
|
|
|
companion object {
|
|
private const val INSTANCE_STATE_ROOT = "instance_state_root"
|
|
private const val INSTANCE_STATE_STATE = "instance_state_state"
|
|
private const val INSTANCE_STATE_SOURCE = "instance_state_source"
|
|
|
|
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 const val CANCEL_THRESHOLD = 0.92f
|
|
|
|
private val COLOR_EVALUATOR = ArgbEvaluatorCompat.getInstance()
|
|
}
|
|
|
|
private val binding: ConversationListFilterPullViewBinding
|
|
private var state: FilterPullState = FilterPullState.CLOSED
|
|
private var source: ConversationFilterSource = ConversationFilterSource.DRAG
|
|
|
|
var onFilterStateChanged: OnFilterStateChanged? = null
|
|
var onCloseClicked: OnCloseClicked? = null
|
|
|
|
init {
|
|
inflate(context, R.layout.conversation_list_filter_pull_view, this)
|
|
binding = ConversationListFilterPullViewBinding.bind(this)
|
|
binding.filterText.setOnClickListener {
|
|
onCloseClicked?.onCloseClicked()
|
|
}
|
|
|
|
doOnEachLayout {
|
|
binding.filterCircle.textFieldMetrics = Pair(binding.filterText.width, binding.filterText.height)
|
|
}
|
|
}
|
|
|
|
private var pillAnimator: Animator? = null
|
|
private var pillColorAnimator: Animator? = null
|
|
private val velocityTracker = ProgressVelocityTracker(5)
|
|
private var animateHelpText = 0
|
|
private var helpTextStartFraction = 0.35f
|
|
|
|
private val pillDefaultBackgroundTint = ContextCompat.getColor(context, R.color.signal_colorSecondaryContainer)
|
|
private val pillWillCloseBackgroundTint = ContextCompat.getColor(context, R.color.signal_colorSurface1)
|
|
|
|
override fun onSaveInstanceState(): Parcelable {
|
|
val root = super.onSaveInstanceState()
|
|
|
|
return bundleOf(
|
|
INSTANCE_STATE_ROOT to root,
|
|
INSTANCE_STATE_STATE to state.name,
|
|
INSTANCE_STATE_SOURCE to source.name
|
|
)
|
|
}
|
|
|
|
override fun onRestoreInstanceState(state: Parcelable?) {
|
|
val bundle = state as Bundle
|
|
val root: Parcelable? = bundle.getParcelable(INSTANCE_STATE_ROOT)
|
|
super.onRestoreInstanceState(root)
|
|
|
|
val restoredState: FilterPullState = FilterPullState.valueOf(bundle.getString(INSTANCE_STATE_STATE)!!)
|
|
val restoredSource: ConversationFilterSource = ConversationFilterSource.valueOf(bundle.getString(INSTANCE_STATE_SOURCE)!!)
|
|
|
|
doOnNextLayout {
|
|
when (restoredState.toLatestSettledState()) {
|
|
FilterPullState.OPEN -> toggle(restoredSource)
|
|
FilterPullState.CLOSED -> Unit
|
|
else -> throw IllegalStateException("Unexpected settled state.")
|
|
}
|
|
}
|
|
}
|
|
|
|
fun onUserDrag(progress: Float) {
|
|
binding.filterCircle.progress = progress
|
|
|
|
if (state == FilterPullState.CLOSED && progress <= 0) {
|
|
setState(FilterPullState.CLOSED, ConversationFilterSource.DRAG)
|
|
} else if ((state == FilterPullState.CLOSED || state == FilterPullState.CANCELING) && progress >= 1f) {
|
|
setState(FilterPullState.OPEN_APEX, ConversationFilterSource.DRAG)
|
|
vibrate()
|
|
resetHelpText()
|
|
resetPillColor()
|
|
} else if (state == FilterPullState.OPEN && progress >= 1f) {
|
|
setState(FilterPullState.CLOSE_APEX, ConversationFilterSource.DRAG)
|
|
vibrate()
|
|
animatePillColor()
|
|
} else if (state == FilterPullState.OPEN_APEX && progress <= CANCEL_THRESHOLD) {
|
|
setState(FilterPullState.CANCELING, ConversationFilterSource.DRAG)
|
|
vibrate()
|
|
}
|
|
|
|
if (state == FilterPullState.CANCELING) {
|
|
binding.filterCircle.alpha = FilterLerp.getCircleCancelAlphaLerp(progress)
|
|
} else {
|
|
binding.filterCircle.alpha = 1f
|
|
}
|
|
|
|
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 {
|
|
binding.filterText.translationY = 0f
|
|
}
|
|
|
|
if (state == FilterPullState.CLOSE_APEX) {
|
|
binding.filterText.alpha = FilterLerp.getPillCloseApexAlphaLerp(progress)
|
|
}
|
|
}
|
|
|
|
fun onUserDragFinished() {
|
|
if (state == FilterPullState.OPEN_APEX) {
|
|
open(ConversationFilterSource.DRAG)
|
|
} else if (state == FilterPullState.CLOSE_APEX || state == FilterPullState.CANCELING) {
|
|
close(ConversationFilterSource.DRAG)
|
|
}
|
|
}
|
|
|
|
fun toggle() {
|
|
toggle(ConversationFilterSource.OVERFLOW)
|
|
}
|
|
|
|
private fun toggle(source: ConversationFilterSource) {
|
|
if (state == FilterPullState.OPEN) {
|
|
resetHelpText()
|
|
setState(FilterPullState.CLOSE_APEX, source)
|
|
close(ConversationFilterSource.OVERFLOW)
|
|
} else if (state == FilterPullState.CLOSED) {
|
|
setState(FilterPullState.OPEN_APEX, source)
|
|
open(ConversationFilterSource.OVERFLOW)
|
|
}
|
|
}
|
|
|
|
fun isCloseable(): Boolean {
|
|
return state == FilterPullState.OPEN
|
|
}
|
|
|
|
private fun open(source: ConversationFilterSource) {
|
|
setState(FilterPullState.OPENING, source)
|
|
animatePillIn(source)
|
|
}
|
|
|
|
private fun close(source: ConversationFilterSource) {
|
|
setState(FilterPullState.CLOSING, source)
|
|
animatePillOut(source)
|
|
}
|
|
|
|
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(source: ConversationFilterSource) {
|
|
binding.filterText.visibility = VISIBLE
|
|
binding.filterText.alpha = 0f
|
|
binding.filterText.isEnabled = true
|
|
|
|
pillAnimator?.cancel()
|
|
pillAnimator = ObjectAnimator.ofFloat(binding.filterText, ALPHA, 1f).apply {
|
|
startDelay = 300
|
|
duration = 300
|
|
doOnEnd {
|
|
setState(FilterPullState.OPEN, source)
|
|
}
|
|
start()
|
|
}
|
|
}
|
|
|
|
private fun animatePillOut(source: ConversationFilterSource) {
|
|
pillAnimator?.cancel()
|
|
pillAnimator = ObjectAnimator.ofFloat(binding.filterText, ALPHA, 0f).apply {
|
|
duration = 150
|
|
doOnEnd {
|
|
binding.filterText.visibility = GONE
|
|
binding.filterText.isEnabled = false
|
|
postDelayed({ setState(FilterPullState.CLOSED, source) }, 150)
|
|
}
|
|
start()
|
|
}
|
|
}
|
|
|
|
private fun animatePillColor() {
|
|
pillColorAnimator?.cancel()
|
|
pillColorAnimator = ValueAnimator.ofInt(pillDefaultBackgroundTint, pillWillCloseBackgroundTint).apply {
|
|
addUpdateListener {
|
|
binding.filterText.chipBackgroundColor = ColorStateList.valueOf(it.animatedValue as Int)
|
|
}
|
|
setEvaluator(COLOR_EVALUATOR)
|
|
duration = 200
|
|
start()
|
|
}
|
|
}
|
|
|
|
private fun resetPillColor() {
|
|
pillColorAnimator?.cancel()
|
|
binding.filterText.chipBackgroundColor = ColorStateList.valueOf(pillDefaultBackgroundTint)
|
|
}
|
|
|
|
private fun setState(state: FilterPullState, source: ConversationFilterSource) {
|
|
this.state = state
|
|
this.source = source
|
|
binding.filterCircle.state = state
|
|
onFilterStateChanged?.newState(state, source)
|
|
}
|
|
|
|
private fun vibrate() {
|
|
if (VibrateUtil.isHapticFeedbackEnabled(context)) {
|
|
VibrateUtil.vibrateTick(context)
|
|
}
|
|
}
|
|
|
|
private fun FilterPullState.toLatestSettledState(): FilterPullState {
|
|
return when (this) {
|
|
FilterPullState.CLOSED, FilterPullState.OPEN_APEX, FilterPullState.OPENING, FilterPullState.CANCELING -> FilterPullState.CLOSED
|
|
FilterPullState.OPEN, FilterPullState.CLOSE_APEX, FilterPullState.CLOSING -> FilterPullState.OPEN
|
|
}
|
|
}
|
|
|
|
interface OnFilterStateChanged {
|
|
fun newState(state: FilterPullState, source: ConversationFilterSource)
|
|
}
|
|
|
|
interface OnCloseClicked {
|
|
fun onCloseClicked()
|
|
}
|
|
}
|