From d79c4775b6ef6f202b4940267f5fa0f42732bb92 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Wed, 21 Dec 2022 11:04:01 -0400 Subject: [PATCH] Add chat filter animation. --- .../ConversationFilterBehavior.kt | 10 +- .../ConversationListFilterPullView.kt | 63 ---- .../ConversationListFragment.java | 58 +++- .../ConversationListViewModel.java | 64 ++-- .../ConversationListFilterPullView.kt | 140 +++++++++ .../chatfilter/FilterCircleView.kt | 292 ++++++++++++++++++ .../chatfilter/FilterPullState.kt | 38 +++ .../thoughtcrime/securesms/util/ViewUtil.java | 7 + .../conversation_list_filter_pull_view.xml | 40 ++- .../res/layout/conversation_list_fragment.xml | 20 +- app/src/main/res/values/strings.xml | 4 + 11 files changed, 608 insertions(+), 128 deletions(-) delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFilterPullView.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversationlist/chatfilter/ConversationListFilterPullView.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversationlist/chatfilter/FilterCircleView.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversationlist/chatfilter/FilterPullState.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationFilterBehavior.kt b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationFilterBehavior.kt index 6537f0c74b..5d1ce87eda 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationFilterBehavior.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationFilterBehavior.kt @@ -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 } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFilterPullView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFilterPullView.kt deleted file mode 100644 index eef77d43a6..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFilterPullView.kt +++ /dev/null @@ -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 - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java index 37374e618b..f6b2019ab3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java @@ -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 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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListViewModel.java index 22cfaba5d3..3e0616bbd7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListViewModel.java @@ -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(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/chatfilter/ConversationListFilterPullView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/chatfilter/ConversationListFilterPullView.kt new file mode 100644 index 0000000000..9cf51e64fe --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/chatfilter/ConversationListFilterPullView.kt @@ -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() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/chatfilter/FilterCircleView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/chatfilter/FilterCircleView.kt new file mode 100644 index 0000000000..0cfb79dff0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/chatfilter/FilterCircleView.kt @@ -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() + + 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 = 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() + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/chatfilter/FilterPullState.kt b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/chatfilter/FilterPullState.kt new file mode 100644 index 0000000000..23122fff9d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/chatfilter/FilterPullState.kt @@ -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; +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtil.java index 8c98383d99..d8cfc99c57 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtil.java @@ -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); diff --git a/app/src/main/res/layout/conversation_list_filter_pull_view.xml b/app/src/main/res/layout/conversation_list_filter_pull_view.xml index 5d3335e4d6..b40fc569cd 100644 --- a/app/src/main/res/layout/conversation_list_filter_pull_view.xml +++ b/app/src/main/res/layout/conversation_list_filter_pull_view.xml @@ -4,24 +4,30 @@ xmlns:tools="http://schemas.android.com/tools" tools:parentTag="android.widget.FrameLayout"> - + - + 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" /> \ No newline at end of file diff --git a/app/src/main/res/layout/conversation_list_fragment.xml b/app/src/main/res/layout/conversation_list_fragment.xml index 3dd2a05358..bfa77e1154 100644 --- a/app/src/main/res/layout/conversation_list_fragment.xml +++ b/app/src/main/res/layout/conversation_list_fragment.xml @@ -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"> - + android:layout_height="wrap_content"> + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ef2f76b42d..49bb3424f1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -5453,4 +5453,8 @@ Donate Payment + + + Filtered by unread +