Add chat filter animation.

main
Alex Hart 2022-12-21 11:04:01 -04:00 zatwierdzone przez Greyson Parrelli
rodzic a13599ae2a
commit d79c4775b6
11 zmienionych plików z 608 dodań i 128 usunięć

Wyświetl plik

@ -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
}
}

Wyświetl plik

@ -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
}
}

Wyświetl plik

@ -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 {

Wyświetl plik

@ -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();

Wyświetl plik

@ -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()
}
}

Wyświetl plik

@ -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()
}
}
}

Wyświetl plik

@ -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;
}

Wyświetl plik

@ -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);

Wyświetl plik

@ -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>

Wyświetl plik

@ -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>

Wyświetl plik

@ -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>