Add chat filter animation.
This commit is contained in:
parent
a13599ae2a
commit
d79c4775b6
11 changed files with 608 additions and 128 deletions
|
@ -11,8 +11,10 @@ import org.thoughtcrime.securesms.util.FeatureFlags
|
|||
|
||||
class ConversationFilterBehavior(context: Context, attributeSet: AttributeSet) : AppBarLayout.Behavior(context, attributeSet) {
|
||||
|
||||
var callback: Callback? = null
|
||||
|
||||
override fun onStartNestedScroll(parent: CoordinatorLayout, child: AppBarLayout, directTargetChild: View, target: View, nestedScrollAxes: Int, type: Int): Boolean {
|
||||
if (type == ViewCompat.TYPE_NON_TOUCH || !FeatureFlags.chatFilters()) {
|
||||
if (type == ViewCompat.TYPE_NON_TOUCH || !FeatureFlags.chatFilters() || callback?.canStartNestedScroll() == false) {
|
||||
return false
|
||||
} else {
|
||||
return super.onStartNestedScroll(parent, child, directTargetChild, target, nestedScrollAxes, type)
|
||||
|
@ -22,5 +24,11 @@ class ConversationFilterBehavior(context: Context, attributeSet: AttributeSet) :
|
|||
override fun onStopNestedScroll(coordinatorLayout: CoordinatorLayout, child: AppBarLayout, target: View, type: Int) {
|
||||
super.onStopNestedScroll(coordinatorLayout, child, target, type)
|
||||
child.setExpanded(false, true)
|
||||
callback?.onStopNestedScroll()
|
||||
}
|
||||
|
||||
interface Callback {
|
||||
fun onStopNestedScroll()
|
||||
fun canStartNestedScroll(): Boolean
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,63 +0,0 @@
|
|||
package org.thoughtcrime.securesms.conversationlist
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import android.util.AttributeSet
|
||||
import android.view.HapticFeedbackConstants
|
||||
import android.widget.FrameLayout
|
||||
import androidx.core.content.ContextCompat
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.databinding.ConversationListFilterPullViewBinding
|
||||
|
||||
/**
|
||||
* Encapsulates the push / pull latch for enabling and disabling
|
||||
* filters into a convenient view.
|
||||
*/
|
||||
class ConversationListFilterPullView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null
|
||||
) : FrameLayout(context, attrs) {
|
||||
|
||||
private val colorPull = ContextCompat.getColor(context, R.color.signal_colorSurface1)
|
||||
private val colorRelease = ContextCompat.getColor(context, R.color.signal_colorSecondaryContainer)
|
||||
private var state: State = State.PULL
|
||||
|
||||
init {
|
||||
inflate(context, R.layout.conversation_list_filter_pull_view, this)
|
||||
setBackgroundColor(colorPull)
|
||||
}
|
||||
|
||||
private val binding = ConversationListFilterPullViewBinding.bind(this)
|
||||
|
||||
fun setToPull() {
|
||||
if (state == State.PULL) {
|
||||
return
|
||||
}
|
||||
|
||||
state = State.PULL
|
||||
setBackgroundColor(colorPull)
|
||||
binding.arrow.setImageResource(R.drawable.ic_arrow_down)
|
||||
binding.text.setText(R.string.ConversationListFilterPullView__pull_down_to_filter)
|
||||
}
|
||||
|
||||
fun setToRelease() {
|
||||
if (state == State.RELEASE) {
|
||||
return
|
||||
}
|
||||
|
||||
if (Settings.System.getInt(context.contentResolver, Settings.System.HAPTIC_FEEDBACK_ENABLED, 0) != 0) {
|
||||
performHapticFeedback(if (Build.VERSION.SDK_INT >= 30) HapticFeedbackConstants.CONFIRM else HapticFeedbackConstants.KEYBOARD_TAP)
|
||||
}
|
||||
|
||||
state = State.RELEASE
|
||||
setBackgroundColor(colorRelease)
|
||||
binding.arrow.setImageResource(R.drawable.ic_arrow_up_16)
|
||||
binding.text.setText(R.string.ConversationListFilterPullView__release_to_filter)
|
||||
}
|
||||
|
||||
enum class State {
|
||||
RELEASE,
|
||||
PULL
|
||||
}
|
||||
}
|
|
@ -55,6 +55,7 @@ import androidx.appcompat.content.res.AppCompatResources;
|
|||
import androidx.appcompat.view.ActionMode;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.appcompat.widget.TooltipCompat;
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.core.view.ViewCompat;
|
||||
import androidx.fragment.app.DialogFragment;
|
||||
|
@ -68,6 +69,7 @@ import com.airbnb.lottie.SimpleColorFilter;
|
|||
import com.annimon.stream.Stream;
|
||||
import com.google.android.material.animation.ArgbEvaluatorCompat;
|
||||
import com.google.android.material.appbar.AppBarLayout;
|
||||
import com.google.android.material.appbar.CollapsingToolbarLayout;
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
import com.google.android.material.snackbar.Snackbar;
|
||||
|
||||
|
@ -112,6 +114,7 @@ import org.thoughtcrime.securesms.components.voice.VoiceNotePlayerView;
|
|||
import org.thoughtcrime.securesms.contacts.sync.CdsPermanentErrorBottomSheet;
|
||||
import org.thoughtcrime.securesms.contacts.sync.CdsTemporaryErrorBottomSheet;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationFragment;
|
||||
import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationListFilterPullView;
|
||||
import org.thoughtcrime.securesms.conversationlist.model.Conversation;
|
||||
import org.thoughtcrime.securesms.conversationlist.model.UnreadPayments;
|
||||
import org.thoughtcrime.securesms.database.MessageTable.MarkedMessageInfo;
|
||||
|
@ -209,6 +212,8 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
|||
private Stub<UnreadPaymentsView> paymentNotificationView;
|
||||
private PulsingFloatingActionButton fab;
|
||||
private PulsingFloatingActionButton cameraFab;
|
||||
private ConversationListFilterPullView pullView;
|
||||
private AppBarLayout pullViewAppBarLayout;
|
||||
private ConversationListViewModel viewModel;
|
||||
private RecyclerView.Adapter activeAdapter;
|
||||
private ConversationListAdapter defaultAdapter;
|
||||
|
@ -268,23 +273,52 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
|||
voiceNotePlayerViewStub = new Stub<>(view.findViewById(R.id.voice_note_player));
|
||||
fab = view.findViewById(R.id.fab);
|
||||
cameraFab = view.findViewById(R.id.camera_fab);
|
||||
pullView = view.findViewById(R.id.pull_view);
|
||||
pullViewAppBarLayout = view.findViewById(R.id.recycler_coordinator_app_bar);
|
||||
|
||||
fab.setVisibility(View.VISIBLE);
|
||||
cameraFab.setVisibility(View.VISIBLE);
|
||||
|
||||
ConversationListFilterPullView pullView = view.findViewById(R.id.pull_view);
|
||||
CollapsingToolbarLayout collapsingToolbarLayout = view.findViewById(R.id.collapsing_toolbar);
|
||||
int minHeight = (int) DimensionUnit.DP.toPixels(52);
|
||||
|
||||
AppBarLayout appBarLayout = view.findViewById(R.id.recycler_coordinator_app_bar);
|
||||
appBarLayout.addOnOffsetChangedListener((layout, verticalOffset) -> {
|
||||
if (verticalOffset == 0) {
|
||||
viewModel.setConversationFilterLatch(ConversationFilterLatch.SET);
|
||||
pullView.setToRelease();
|
||||
} else if (verticalOffset == -layout.getHeight()) {
|
||||
viewModel.setConversationFilterLatch(ConversationFilterLatch.RESET);
|
||||
pullView.setToPull();
|
||||
pullView.setOnFilterStateChanged(state -> {
|
||||
switch (state) {
|
||||
case CLOSING:
|
||||
viewModel.setFiltered(false);
|
||||
break;
|
||||
case OPENING:
|
||||
viewModel.setFiltered(true);
|
||||
break;
|
||||
case OPEN_APEX:
|
||||
ViewUtil.setMinimumHeight(collapsingToolbarLayout, minHeight);
|
||||
break;
|
||||
case CLOSE_APEX:
|
||||
ViewUtil.setMinimumHeight(collapsingToolbarLayout, 0);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
pullView.setOnCloseClicked(this::onClearFilterClick);
|
||||
|
||||
ConversationFilterBehavior conversationFilterBehavior = Objects.requireNonNull((ConversationFilterBehavior) ((CoordinatorLayout.LayoutParams) pullViewAppBarLayout.getLayoutParams()).getBehavior());
|
||||
conversationFilterBehavior.setCallback(new ConversationFilterBehavior.Callback() {
|
||||
@Override
|
||||
public void onStopNestedScroll() {
|
||||
pullView.onUserDragFinished();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean canStartNestedScroll() {
|
||||
return !isSearchOpen() || pullView.isCloseable();
|
||||
}
|
||||
});
|
||||
|
||||
pullViewAppBarLayout.addOnOffsetChangedListener((layout, verticalOffset) -> {
|
||||
float progress = 1 - ((float) verticalOffset) / (-layout.getHeight());
|
||||
pullView.onUserDrag(progress);
|
||||
});
|
||||
|
||||
fab.show();
|
||||
cameraFab.show();
|
||||
|
||||
|
@ -982,7 +1016,8 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
|||
}
|
||||
|
||||
private void handleFilterUnreadChats() {
|
||||
viewModel.toggleUnreadChatsFilter();
|
||||
pullView.toggle();
|
||||
pullViewAppBarLayout.setExpanded(false, true);
|
||||
}
|
||||
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
|
@ -1479,7 +1514,8 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
|||
|
||||
@Override
|
||||
public void onClearFilterClick() {
|
||||
viewModel.toggleUnreadChatsFilter();
|
||||
pullView.toggle();
|
||||
pullViewAppBarLayout.setExpanded(false, true);
|
||||
}
|
||||
|
||||
private class PaymentNotificationListener implements UnreadPaymentsView.Listener {
|
||||
|
|
|
@ -83,7 +83,6 @@ class ConversationListViewModel extends ViewModel {
|
|||
private String activeQuery;
|
||||
private SearchResult activeSearchResult;
|
||||
private int pinnedCount;
|
||||
private ConversationFilterLatch conversationFilterLatch;
|
||||
|
||||
private ConversationListViewModel(@NonNull SearchRepository searchRepository, boolean isArchived) {
|
||||
this.megaphone = new MutableLiveData<>();
|
||||
|
@ -101,8 +100,8 @@ class ConversationListViewModel extends ViewModel {
|
|||
this.invalidator = new Invalidator();
|
||||
this.disposables = new CompositeDisposable();
|
||||
this.conversationFilter = new MutableLiveData<>(ConversationFilter.OFF);
|
||||
this.conversationFilterLatch = ConversationFilterLatch.RESET;
|
||||
this.conversationListDataSource = Transformations.map(conversationFilter, filter -> ConversationListDataSource.create(filter, isArchived));
|
||||
this.conversationListDataSource = Transformations.map(Transformations.distinctUntilChanged(conversationFilter),
|
||||
filter -> ConversationListDataSource.create(filter, isArchived));
|
||||
this.pagedData = Transformations.map(conversationListDataSource, source -> PagedData.createForLiveData(source,
|
||||
new PagingConfig.Builder()
|
||||
.setPageSize(15)
|
||||
|
@ -212,22 +211,17 @@ class ConversationListViewModel extends ViewModel {
|
|||
setSelection(newSelection);
|
||||
}
|
||||
|
||||
void setConversationFilterLatch(@NonNull ConversationFilterLatch latch) {
|
||||
ConversationFilterLatch previous = conversationFilterLatch;
|
||||
conversationFilterLatch = latch;
|
||||
if (previous != latch && latch == ConversationFilterLatch.RESET) {
|
||||
toggleUnreadChatsFilter();
|
||||
}
|
||||
}
|
||||
|
||||
public void toggleUnreadChatsFilter() {
|
||||
ConversationFilter filter = Objects.requireNonNull(conversationFilter.getValue());
|
||||
if (filter == ConversationFilter.UNREAD) {
|
||||
Log.d(TAG, "Setting filter to OFF");
|
||||
conversationFilter.setValue(ConversationFilter.OFF);
|
||||
} else {
|
||||
Log.d(TAG, "Setting filter to UNREAD");
|
||||
void setFiltered(boolean isFiltered) {
|
||||
if (isFiltered) {
|
||||
conversationFilter.setValue(ConversationFilter.UNREAD);
|
||||
if (activeQuery != null) {
|
||||
onSearchQueryUpdated(activeQuery);
|
||||
}
|
||||
} else {
|
||||
conversationFilter.setValue(ConversationFilter.OFF);
|
||||
if (activeQuery != null) {
|
||||
onSearchQueryUpdated(activeQuery);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -272,19 +266,14 @@ class ConversationListViewModel extends ViewModel {
|
|||
void onSearchQueryUpdated(String query) {
|
||||
activeQuery = query;
|
||||
|
||||
ConversationFilter filter = conversationFilter.getValue();
|
||||
if (filter != ConversationFilter.OFF) {
|
||||
contactSearchDebouncer.publish(() -> submitConversationSearch(query));
|
||||
return;
|
||||
}
|
||||
|
||||
contactSearchDebouncer.publish(() -> {
|
||||
searchRepository.queryThreads(query, result -> {
|
||||
if (!result.getQuery().equals(activeQuery)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!activeSearchResult.getQuery().equals(activeQuery)) {
|
||||
activeSearchResult = SearchResult.EMPTY;
|
||||
}
|
||||
|
||||
activeSearchResult = activeSearchResult.merge(result);
|
||||
searchResult.postValue(activeSearchResult);
|
||||
});
|
||||
submitConversationSearch(query);
|
||||
|
||||
searchRepository.queryContacts(query, result -> {
|
||||
if (!result.getQuery().equals(activeQuery)) {
|
||||
|
@ -316,6 +305,21 @@ class ConversationListViewModel extends ViewModel {
|
|||
});
|
||||
}
|
||||
|
||||
private void submitConversationSearch(@NonNull String query) {
|
||||
searchRepository.queryThreads(query, result -> {
|
||||
if (!result.getQuery().equals(activeQuery)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!activeSearchResult.getQuery().equals(activeQuery)) {
|
||||
activeSearchResult = SearchResult.EMPTY;
|
||||
}
|
||||
|
||||
activeSearchResult = activeSearchResult.merge(result);
|
||||
searchResult.postValue(activeSearchResult);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCleared() {
|
||||
invalidator.invalidate();
|
||||
|
|
|
@ -0,0 +1,140 @@
|
|||
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.signal.core.util.dp
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.databinding.ConversationListFilterPullViewBinding
|
||||
|
||||
/**
|
||||
* 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 val EVAL = FloatEvaluator()
|
||||
}
|
||||
|
||||
private val binding: ConversationListFilterPullViewBinding
|
||||
private var state: FilterPullState = FilterPullState.CLOSED
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
private var pillAnimator: Animator? = null
|
||||
|
||||
fun onUserDrag(progress: Float) {
|
||||
binding.filterCircle.textFieldMetrics = Pair(binding.filterText.width, binding.filterText.height)
|
||||
binding.filterCircle.progress = progress
|
||||
|
||||
if (state == FilterPullState.CLOSED && progress <= 0) {
|
||||
setState(FilterPullState.CLOSED)
|
||||
} else if (state == FilterPullState.CLOSED && progress >= 1f) {
|
||||
setState(FilterPullState.OPEN_APEX)
|
||||
} else if (state == FilterPullState.OPEN && progress >= 1f) {
|
||||
setState(FilterPullState.CLOSE_APEX)
|
||||
}
|
||||
|
||||
// If we are pulling toward the open apex
|
||||
if (state == FilterPullState.OPEN || state == FilterPullState.CLOSE_APEX || state == FilterPullState.CLOSING) {
|
||||
binding.filterText.translationY = EVAL.evaluate(progress, 26.dp, -24.dp.toFloat())
|
||||
} else {
|
||||
binding.filterText.translationY = 0f
|
||||
}
|
||||
}
|
||||
|
||||
fun onUserDragFinished() {
|
||||
if (state == FilterPullState.OPEN_APEX) {
|
||||
open()
|
||||
} else if (state == FilterPullState.CLOSE_APEX) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
fun toggle() {
|
||||
if (state == FilterPullState.OPEN) {
|
||||
setState(FilterPullState.CLOSE_APEX)
|
||||
close()
|
||||
} else if (state == FilterPullState.CLOSED) {
|
||||
setState(FilterPullState.OPEN_APEX)
|
||||
open()
|
||||
}
|
||||
}
|
||||
|
||||
fun isCloseable(): Boolean {
|
||||
return state == FilterPullState.OPEN
|
||||
}
|
||||
|
||||
private fun open() {
|
||||
setState(FilterPullState.OPENING)
|
||||
animatePillIn()
|
||||
}
|
||||
|
||||
private fun close() {
|
||||
setState(FilterPullState.CLOSING)
|
||||
animatePillOut()
|
||||
}
|
||||
|
||||
private fun animatePillIn() {
|
||||
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)
|
||||
}
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
private fun animatePillOut() {
|
||||
pillAnimator?.cancel()
|
||||
pillAnimator = ObjectAnimator.ofFloat(binding.filterText, ALPHA, 0f).apply {
|
||||
duration = 300
|
||||
doOnEnd {
|
||||
binding.filterText.visibility = GONE
|
||||
binding.filterText.isEnabled = false
|
||||
setState(FilterPullState.CLOSED)
|
||||
}
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
private fun setState(state: FilterPullState) {
|
||||
this.state = state
|
||||
binding.filterCircle.state = state
|
||||
onFilterStateChanged?.newState(state)
|
||||
}
|
||||
|
||||
interface OnFilterStateChanged {
|
||||
fun newState(state: FilterPullState)
|
||||
}
|
||||
|
||||
interface OnCloseClicked {
|
||||
fun onCloseClicked()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,292 @@
|
|||
package org.thoughtcrime.securesms.conversationlist.chatfilter
|
||||
|
||||
import android.animation.FloatEvaluator
|
||||
import android.animation.ValueAnimator
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Rect
|
||||
import android.graphics.RectF
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.view.animation.OvershootInterpolator
|
||||
import androidx.annotation.Px
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.google.android.material.animation.ArgbEvaluatorCompat
|
||||
import org.signal.core.util.dp
|
||||
import org.thoughtcrime.securesms.R
|
||||
import kotlin.math.max
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/**
|
||||
* Renders the filter-circle at any given position
|
||||
*
|
||||
* Animation Spec:
|
||||
*
|
||||
* @ 35dp display open, we want to start animating the first stroke:
|
||||
* - duration 100ms
|
||||
* - curve Quad in/out
|
||||
*
|
||||
* @ 50dp display open, we want to start animating the second stroke:
|
||||
* - duration 150ms
|
||||
* - curve Quad in/out
|
||||
*
|
||||
* @ 75dp display open, we want to start animating the third stroke:
|
||||
* - duration 150ms
|
||||
* - curve Quad in/out
|
||||
*
|
||||
* @ 100dp display open, we want to apply "active" coloring.
|
||||
*
|
||||
* On release, if active, we transform into a rounded rectangle
|
||||
* - 38pt circle
|
||||
* - rectangle width 154, height 32
|
||||
* - duration 100ms
|
||||
* - fade in button and text 300ms *after* circle-rectangle animation has completed.
|
||||
*/
|
||||
class FilterCircleView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : View(context, attrs, defStyleAttr) {
|
||||
|
||||
companion object {
|
||||
private val CIRCLE_Y_EVALUATOR = FloatEvaluator()
|
||||
private val COLOR_EVALUATOR = ArgbEvaluatorCompat.getInstance()
|
||||
|
||||
private val STROKES = listOf(
|
||||
Stroke(
|
||||
triggerPoint = 0.35f,
|
||||
width = 4.dp,
|
||||
distanceFromBottomOfCircle = 11.dp,
|
||||
animationDuration = 100.milliseconds
|
||||
),
|
||||
Stroke(
|
||||
triggerPoint = 0.5f,
|
||||
width = 12.dp,
|
||||
distanceFromBottomOfCircle = 17.dp,
|
||||
animationDuration = 150.milliseconds
|
||||
),
|
||||
Stroke(
|
||||
triggerPoint = 0.75f,
|
||||
width = 18.dp,
|
||||
distanceFromBottomOfCircle = 23.dp,
|
||||
animationDuration = 150.milliseconds
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private val circleRadius = 38.dp / 2f
|
||||
private val circleBackgroundColor = ContextCompat.getColor(context, R.color.signal_colorSurface1)
|
||||
private val strokeColor = ContextCompat.getColor(context, R.color.signal_colorSecondary)
|
||||
private val circleActiveBackgroundColor = ContextCompat.getColor(context, R.color.signal_colorSecondaryContainer)
|
||||
private val strokeActiveColor = ContextCompat.getColor(context, R.color.signal_colorPrimary)
|
||||
|
||||
private var circleColorAnimator: ValueAnimator? = null
|
||||
private var strokeColorAnimator: ValueAnimator? = null
|
||||
private var circleToRectangleAnimator: ValueAnimator? = null
|
||||
|
||||
private val runningStrokeAnimations = mutableMapOf<Stroke, ValueAnimator>()
|
||||
|
||||
private val circlePaint = Paint().apply {
|
||||
isAntiAlias = true
|
||||
color = circleBackgroundColor
|
||||
style = Paint.Style.FILL
|
||||
}
|
||||
|
||||
private val strokePaint = Paint().apply {
|
||||
isAntiAlias = true
|
||||
color = strokeColor
|
||||
style = Paint.Style.FILL
|
||||
}
|
||||
|
||||
var progress: Float = 0f
|
||||
set(value) {
|
||||
field = value
|
||||
onStateChange()
|
||||
}
|
||||
|
||||
var state: FilterPullState = FilterPullState.CLOSED
|
||||
set(value) {
|
||||
field = value
|
||||
onStateChange()
|
||||
}
|
||||
|
||||
var textFieldMetrics: Pair<Int, Int> = Pair(0, 0)
|
||||
|
||||
private val rect = Rect()
|
||||
private val rectF = RectF()
|
||||
var bottomOffset: Float = evaluateBottomOffset(0f, FilterPullState.CLOSED)
|
||||
|
||||
override fun draw(canvas: Canvas) {
|
||||
super.draw(canvas)
|
||||
canvas.getClipBounds(rect)
|
||||
|
||||
val centerX = rect.width() / 2f
|
||||
val circleBottom = rect.height() - bottomOffset
|
||||
val circleCenterY = circleBottom - circleRadius
|
||||
|
||||
val circleShapeAnimator = circleToRectangleAnimator
|
||||
if (circleShapeAnimator != null) {
|
||||
val (textWidth, textHeight) = textFieldMetrics
|
||||
rectF.set(
|
||||
CIRCLE_Y_EVALUATOR.evaluate(circleShapeAnimator.animatedValue as Float, centerX - circleRadius, centerX - (textWidth / 2)),
|
||||
CIRCLE_Y_EVALUATOR.evaluate(circleShapeAnimator.animatedFraction, circleCenterY - circleRadius, circleCenterY - (textHeight / 2)),
|
||||
CIRCLE_Y_EVALUATOR.evaluate(circleShapeAnimator.animatedValue as Float, centerX + circleRadius, centerX + (textWidth / 2)),
|
||||
CIRCLE_Y_EVALUATOR.evaluate(circleShapeAnimator.animatedFraction, circleCenterY + circleRadius, circleCenterY + (textHeight / 2))
|
||||
)
|
||||
|
||||
canvas.drawRoundRect(
|
||||
rectF,
|
||||
CIRCLE_Y_EVALUATOR.evaluate(circleShapeAnimator.animatedFraction, circleRadius, 8.dp),
|
||||
CIRCLE_Y_EVALUATOR.evaluate(circleShapeAnimator.animatedFraction, circleRadius, 8.dp),
|
||||
getCirclePaint()
|
||||
)
|
||||
} else {
|
||||
rectF.set(
|
||||
centerX - circleRadius,
|
||||
circleBottom - circleRadius * 2,
|
||||
centerX + circleRadius,
|
||||
circleBottom
|
||||
)
|
||||
|
||||
canvas.drawRoundRect(
|
||||
rectF,
|
||||
circleRadius,
|
||||
circleRadius,
|
||||
getCirclePaint()
|
||||
)
|
||||
}
|
||||
|
||||
runningStrokeAnimations.forEach { (stroke, animator) ->
|
||||
stroke.fillRect(rect, centerX, circleBottom, animator.animatedFraction)
|
||||
rectF.set(rect)
|
||||
canvas.drawRoundRect(rectF, 50f, 50f, getStrokePaint())
|
||||
}
|
||||
}
|
||||
|
||||
private fun onStateChange() {
|
||||
bottomOffset = evaluateBottomOffset(progress, state)
|
||||
checkStrokeTriggers(progress)
|
||||
checkColorAnimators(state)
|
||||
checkCircleToRectangleAnimator(state)
|
||||
invalidate()
|
||||
}
|
||||
|
||||
private fun evaluateBottomOffset(progress: Float, state: FilterPullState): Float {
|
||||
return when (state) {
|
||||
FilterPullState.OPEN_APEX, FilterPullState.OPENING, FilterPullState.OPEN, FilterPullState.CLOSE_APEX -> CIRCLE_Y_EVALUATOR.evaluate(progress, (-46).dp, 55.dp)
|
||||
FilterPullState.CLOSED, FilterPullState.CLOSING -> CIRCLE_Y_EVALUATOR.evaluate(progress, 0.dp, 55.dp)
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkColorAnimators(state: FilterPullState) {
|
||||
if (state != FilterPullState.CLOSED) {
|
||||
if (circleColorAnimator == null) {
|
||||
circleColorAnimator = ValueAnimator
|
||||
.ofInt(circleBackgroundColor, circleActiveBackgroundColor).apply {
|
||||
addUpdateListener { invalidate() }
|
||||
setEvaluator(COLOR_EVALUATOR)
|
||||
duration = 200
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
if (strokeColorAnimator == null) {
|
||||
strokeColorAnimator = ValueAnimator
|
||||
.ofInt(strokeColor, strokeActiveColor).apply {
|
||||
addUpdateListener { invalidate() }
|
||||
setEvaluator(COLOR_EVALUATOR)
|
||||
duration = 200
|
||||
start()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
circleColorAnimator?.cancel()
|
||||
circleColorAnimator = null
|
||||
|
||||
strokeColorAnimator?.cancel()
|
||||
strokeColorAnimator = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkStrokeTriggers(progress: Float) {
|
||||
if (progress <= 0f) {
|
||||
runningStrokeAnimations.forEach { it.value.cancel() }
|
||||
runningStrokeAnimations.clear()
|
||||
return
|
||||
}
|
||||
|
||||
STROKES
|
||||
.filter { it.triggerPoint <= progress && !runningStrokeAnimations.containsKey(it) }
|
||||
.forEach {
|
||||
runningStrokeAnimations[it] = ValueAnimator.ofFloat(0f, 1f).apply {
|
||||
addUpdateListener { invalidate() }
|
||||
duration = it.animationDuration.inWholeMilliseconds
|
||||
start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkCircleToRectangleAnimator(state: FilterPullState) {
|
||||
if (state == FilterPullState.OPENING && circleToRectangleAnimator == null) {
|
||||
require(textFieldMetrics != Pair(0, 0))
|
||||
circleToRectangleAnimator = ValueAnimator.ofFloat(1f).apply {
|
||||
addUpdateListener { invalidate() }
|
||||
interpolator = OvershootInterpolator()
|
||||
startDelay = 100
|
||||
duration = 200
|
||||
start()
|
||||
}
|
||||
} else if (state == FilterPullState.CLOSED) {
|
||||
circleToRectangleAnimator?.cancel()
|
||||
circleToRectangleAnimator = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun getCirclePaint(): Paint {
|
||||
val circleAlpha = when (state) {
|
||||
FilterPullState.CLOSED -> 255
|
||||
FilterPullState.OPEN_APEX -> 255
|
||||
FilterPullState.OPENING -> 255
|
||||
FilterPullState.OPEN -> 0
|
||||
FilterPullState.CLOSE_APEX -> 0
|
||||
FilterPullState.CLOSING -> 0
|
||||
}
|
||||
|
||||
return circlePaint.apply {
|
||||
color = (circleColorAnimator?.animatedValue ?: circleBackgroundColor) as Int
|
||||
alpha = circleAlpha
|
||||
}
|
||||
}
|
||||
|
||||
private fun getStrokePaint(): Paint {
|
||||
val strokeAlpha = max(0f, 1f - (circleToRectangleAnimator?.animatedFraction ?: 0f))
|
||||
|
||||
return strokePaint.apply {
|
||||
color = (strokeColorAnimator?.animatedValue ?: strokeColor) as Int
|
||||
alpha = (strokeAlpha * 255).toInt()
|
||||
}
|
||||
}
|
||||
|
||||
private data class Stroke(
|
||||
val triggerPoint: Float,
|
||||
@Px val width: Int,
|
||||
@Px val distanceFromBottomOfCircle: Int,
|
||||
val animationDuration: Duration
|
||||
) {
|
||||
fun fillRect(rect: Rect, centerX: Float, circleBottom: Float, progress: Float) {
|
||||
rect.setEmpty()
|
||||
|
||||
val width = progress * this.width
|
||||
if (width <= 0f) {
|
||||
return
|
||||
}
|
||||
|
||||
rect.bottom = (circleBottom.toInt() - distanceFromBottomOfCircle)
|
||||
rect.top = rect.bottom - 2.dp
|
||||
rect.left = (centerX - (width / 2f)).toInt()
|
||||
rect.right = (centerX + (width / 2f)).toInt()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
package org.thoughtcrime.securesms.conversationlist.chatfilter
|
||||
|
||||
/**
|
||||
* Represents the state of the filter pull.
|
||||
*/
|
||||
enum class FilterPullState {
|
||||
/**
|
||||
* The filter is not active. Releasing the filter will cause it to slide shut.
|
||||
* Pulling the filter to 100% will move to apex.
|
||||
*/
|
||||
CLOSED,
|
||||
|
||||
/**
|
||||
* The filter has been dragged all the way to the end of it's space. This is considered
|
||||
* the "apex" point. The only action here is that the user can release to move to the open state.
|
||||
*/
|
||||
OPEN_APEX,
|
||||
|
||||
/**
|
||||
* The filter is being activated and the animation is running.
|
||||
*/
|
||||
OPENING,
|
||||
|
||||
/**
|
||||
* The filter is active and the animation has settled.
|
||||
*/
|
||||
OPEN,
|
||||
|
||||
/**
|
||||
* From the open position, the user has dragged to the apex again.
|
||||
*/
|
||||
CLOSE_APEX,
|
||||
|
||||
/**
|
||||
* The filter is being removed and the animation is running
|
||||
*/
|
||||
CLOSING;
|
||||
}
|
|
@ -38,6 +38,7 @@ import androidx.annotation.IdRes;
|
|||
import androidx.annotation.LayoutRes;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.Px;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.appcompat.view.ContextThemeWrapper;
|
||||
import androidx.core.view.ViewCompat;
|
||||
|
@ -54,6 +55,12 @@ public final class ViewUtil {
|
|||
private ViewUtil() {
|
||||
}
|
||||
|
||||
public static void setMinimumHeight(@NonNull View view, @Px int minimumHeight) {
|
||||
if (view.getMinimumHeight() != minimumHeight) {
|
||||
view.setMinimumHeight(minimumHeight);
|
||||
}
|
||||
}
|
||||
|
||||
public static void focusAndMoveCursorToEndAndOpenKeyboard(@NonNull EditText input) {
|
||||
int numberLength = input.getText().length();
|
||||
input.setSelection(numberLength, numberLength);
|
||||
|
|
|
@ -4,24 +4,30 @@
|
|||
xmlns:tools="http://schemas.android.com/tools"
|
||||
tools:parentTag="android.widget.FrameLayout">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom"
|
||||
android:layout_marginStart="@dimen/dsl_settings_gutter"
|
||||
android:layout_marginBottom="11dp"
|
||||
android:text="@string/ConversationListFilterPullView__pull_down_to_filter"
|
||||
android:textAppearance="@style/Signal.Text.LabelLarge" />
|
||||
<org.thoughtcrime.securesms.conversationlist.chatfilter.FilterCircleView
|
||||
android:id="@+id/filter_circle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/arrow"
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/filter_text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom|center_horizontal"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:importantForAccessibility="no"
|
||||
app:srcCompat="@drawable/ic_arrow_down"
|
||||
app:tint="@color/signal_colorOnSurface" />
|
||||
android:layout_height="32dp"
|
||||
android:layout_gravity="center_horizontal|bottom"
|
||||
android:layout_marginBottom="10dp"
|
||||
android:enabled="false"
|
||||
android:text="@string/ChatFilter__filtered_by_unread"
|
||||
android:textAppearance="@style/Signal.Text.LabelLarge"
|
||||
android:textColor="@color/signal_colorOnSurface"
|
||||
android:visibility="invisible"
|
||||
app:chipBackgroundColor="@color/signal_colorSecondaryContainer"
|
||||
app:chipCornerRadius="8dp"
|
||||
app:chipMinHeight="32dp"
|
||||
app:closeIcon="@drawable/ic_x_20"
|
||||
app:closeIconEnabled="true"
|
||||
app:closeIconSize="18dp"
|
||||
app:ensureMinTouchTargetSize="false"
|
||||
app:icon="@drawable/ic_x_20"
|
||||
app:iconTint="@color/signal_colorOnSurface" />
|
||||
|
||||
</merge>
|
|
@ -50,16 +50,24 @@
|
|||
android:id="@+id/recycler_coordinator_app_bar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:elevation="0dp"
|
||||
app:expanded="false"
|
||||
app:layout_behavior="org.thoughtcrime.securesms.conversationlist.ConversationFilterBehavior">
|
||||
|
||||
<org.thoughtcrime.securesms.conversationlist.ConversationListFilterPullView
|
||||
android:id="@+id/pull_view"
|
||||
<com.google.android.material.appbar.CollapsingToolbarLayout
|
||||
android:id="@+id/collapsing_toolbar"
|
||||
app:layout_scrollFlags="scroll|enterAlwaysCollapsed|exitUntilCollapsed"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="80dp"
|
||||
android:background="@color/signal_colorSurface1"
|
||||
app:layout_scrollFlags="scroll|enterAlwaysCollapsed"
|
||||
app:layout_scrollInterpolator="@android:anim/decelerate_interpolator" />
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationListFilterPullView
|
||||
android:id="@+id/pull_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="100dp"
|
||||
android:background="@color/signal_colorBackground"
|
||||
app:layout_scrollInterpolator="@android:anim/linear_interpolator" />
|
||||
|
||||
</com.google.android.material.appbar.CollapsingToolbarLayout>
|
||||
|
||||
</com.google.android.material.appbar.AppBarLayout>
|
||||
|
||||
|
|
|
@ -5453,4 +5453,8 @@
|
|||
<string name="PaypalCompleteOrderBottomSheet__donate">Donate</string>
|
||||
<string name="PaypalCompleteOrderBottomSheet__payment">Payment</string>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
</resources>
|
||||
|
|
Loading…
Add table
Reference in a new issue