kopia lustrzana https://github.com/ryukoposting/Signal-Android
Add chat filter animation.
rodzic
a13599ae2a
commit
d79c4775b6
|
@ -11,8 +11,10 @@ import org.thoughtcrime.securesms.util.FeatureFlags
|
||||||
|
|
||||||
class ConversationFilterBehavior(context: Context, attributeSet: AttributeSet) : AppBarLayout.Behavior(context, attributeSet) {
|
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 {
|
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
|
return false
|
||||||
} else {
|
} else {
|
||||||
return super.onStartNestedScroll(parent, child, directTargetChild, target, nestedScrollAxes, type)
|
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) {
|
override fun onStopNestedScroll(coordinatorLayout: CoordinatorLayout, child: AppBarLayout, target: View, type: Int) {
|
||||||
super.onStopNestedScroll(coordinatorLayout, child, target, type)
|
super.onStopNestedScroll(coordinatorLayout, child, target, type)
|
||||||
child.setExpanded(false, true)
|
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.view.ActionMode;
|
||||||
import androidx.appcompat.widget.Toolbar;
|
import androidx.appcompat.widget.Toolbar;
|
||||||
import androidx.appcompat.widget.TooltipCompat;
|
import androidx.appcompat.widget.TooltipCompat;
|
||||||
|
import androidx.coordinatorlayout.widget.CoordinatorLayout;
|
||||||
import androidx.core.content.ContextCompat;
|
import androidx.core.content.ContextCompat;
|
||||||
import androidx.core.view.ViewCompat;
|
import androidx.core.view.ViewCompat;
|
||||||
import androidx.fragment.app.DialogFragment;
|
import androidx.fragment.app.DialogFragment;
|
||||||
|
@ -68,6 +69,7 @@ import com.airbnb.lottie.SimpleColorFilter;
|
||||||
import com.annimon.stream.Stream;
|
import com.annimon.stream.Stream;
|
||||||
import com.google.android.material.animation.ArgbEvaluatorCompat;
|
import com.google.android.material.animation.ArgbEvaluatorCompat;
|
||||||
import com.google.android.material.appbar.AppBarLayout;
|
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.dialog.MaterialAlertDialogBuilder;
|
||||||
import com.google.android.material.snackbar.Snackbar;
|
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.CdsPermanentErrorBottomSheet;
|
||||||
import org.thoughtcrime.securesms.contacts.sync.CdsTemporaryErrorBottomSheet;
|
import org.thoughtcrime.securesms.contacts.sync.CdsTemporaryErrorBottomSheet;
|
||||||
import org.thoughtcrime.securesms.conversation.ConversationFragment;
|
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.Conversation;
|
||||||
import org.thoughtcrime.securesms.conversationlist.model.UnreadPayments;
|
import org.thoughtcrime.securesms.conversationlist.model.UnreadPayments;
|
||||||
import org.thoughtcrime.securesms.database.MessageTable.MarkedMessageInfo;
|
import org.thoughtcrime.securesms.database.MessageTable.MarkedMessageInfo;
|
||||||
|
@ -209,6 +212,8 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||||
private Stub<UnreadPaymentsView> paymentNotificationView;
|
private Stub<UnreadPaymentsView> paymentNotificationView;
|
||||||
private PulsingFloatingActionButton fab;
|
private PulsingFloatingActionButton fab;
|
||||||
private PulsingFloatingActionButton cameraFab;
|
private PulsingFloatingActionButton cameraFab;
|
||||||
|
private ConversationListFilterPullView pullView;
|
||||||
|
private AppBarLayout pullViewAppBarLayout;
|
||||||
private ConversationListViewModel viewModel;
|
private ConversationListViewModel viewModel;
|
||||||
private RecyclerView.Adapter activeAdapter;
|
private RecyclerView.Adapter activeAdapter;
|
||||||
private ConversationListAdapter defaultAdapter;
|
private ConversationListAdapter defaultAdapter;
|
||||||
|
@ -268,23 +273,52 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||||
voiceNotePlayerViewStub = new Stub<>(view.findViewById(R.id.voice_note_player));
|
voiceNotePlayerViewStub = new Stub<>(view.findViewById(R.id.voice_note_player));
|
||||||
fab = view.findViewById(R.id.fab);
|
fab = view.findViewById(R.id.fab);
|
||||||
cameraFab = view.findViewById(R.id.camera_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);
|
fab.setVisibility(View.VISIBLE);
|
||||||
cameraFab.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);
|
pullView.setOnFilterStateChanged(state -> {
|
||||||
appBarLayout.addOnOffsetChangedListener((layout, verticalOffset) -> {
|
switch (state) {
|
||||||
if (verticalOffset == 0) {
|
case CLOSING:
|
||||||
viewModel.setConversationFilterLatch(ConversationFilterLatch.SET);
|
viewModel.setFiltered(false);
|
||||||
pullView.setToRelease();
|
break;
|
||||||
} else if (verticalOffset == -layout.getHeight()) {
|
case OPENING:
|
||||||
viewModel.setConversationFilterLatch(ConversationFilterLatch.RESET);
|
viewModel.setFiltered(true);
|
||||||
pullView.setToPull();
|
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();
|
fab.show();
|
||||||
cameraFab.show();
|
cameraFab.show();
|
||||||
|
|
||||||
|
@ -982,7 +1016,8 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||||
}
|
}
|
||||||
|
|
||||||
private void handleFilterUnreadChats() {
|
private void handleFilterUnreadChats() {
|
||||||
viewModel.toggleUnreadChatsFilter();
|
pullView.toggle();
|
||||||
|
pullViewAppBarLayout.setExpanded(false, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("StaticFieldLeak")
|
@SuppressLint("StaticFieldLeak")
|
||||||
|
@ -1479,7 +1514,8 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onClearFilterClick() {
|
public void onClearFilterClick() {
|
||||||
viewModel.toggleUnreadChatsFilter();
|
pullView.toggle();
|
||||||
|
pullViewAppBarLayout.setExpanded(false, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
private class PaymentNotificationListener implements UnreadPaymentsView.Listener {
|
private class PaymentNotificationListener implements UnreadPaymentsView.Listener {
|
||||||
|
|
|
@ -83,7 +83,6 @@ class ConversationListViewModel extends ViewModel {
|
||||||
private String activeQuery;
|
private String activeQuery;
|
||||||
private SearchResult activeSearchResult;
|
private SearchResult activeSearchResult;
|
||||||
private int pinnedCount;
|
private int pinnedCount;
|
||||||
private ConversationFilterLatch conversationFilterLatch;
|
|
||||||
|
|
||||||
private ConversationListViewModel(@NonNull SearchRepository searchRepository, boolean isArchived) {
|
private ConversationListViewModel(@NonNull SearchRepository searchRepository, boolean isArchived) {
|
||||||
this.megaphone = new MutableLiveData<>();
|
this.megaphone = new MutableLiveData<>();
|
||||||
|
@ -101,8 +100,8 @@ class ConversationListViewModel extends ViewModel {
|
||||||
this.invalidator = new Invalidator();
|
this.invalidator = new Invalidator();
|
||||||
this.disposables = new CompositeDisposable();
|
this.disposables = new CompositeDisposable();
|
||||||
this.conversationFilter = new MutableLiveData<>(ConversationFilter.OFF);
|
this.conversationFilter = new MutableLiveData<>(ConversationFilter.OFF);
|
||||||
this.conversationFilterLatch = ConversationFilterLatch.RESET;
|
this.conversationListDataSource = Transformations.map(Transformations.distinctUntilChanged(conversationFilter),
|
||||||
this.conversationListDataSource = Transformations.map(conversationFilter, filter -> ConversationListDataSource.create(filter, isArchived));
|
filter -> ConversationListDataSource.create(filter, isArchived));
|
||||||
this.pagedData = Transformations.map(conversationListDataSource, source -> PagedData.createForLiveData(source,
|
this.pagedData = Transformations.map(conversationListDataSource, source -> PagedData.createForLiveData(source,
|
||||||
new PagingConfig.Builder()
|
new PagingConfig.Builder()
|
||||||
.setPageSize(15)
|
.setPageSize(15)
|
||||||
|
@ -212,22 +211,17 @@ class ConversationListViewModel extends ViewModel {
|
||||||
setSelection(newSelection);
|
setSelection(newSelection);
|
||||||
}
|
}
|
||||||
|
|
||||||
void setConversationFilterLatch(@NonNull ConversationFilterLatch latch) {
|
void setFiltered(boolean isFiltered) {
|
||||||
ConversationFilterLatch previous = conversationFilterLatch;
|
if (isFiltered) {
|
||||||
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");
|
|
||||||
conversationFilter.setValue(ConversationFilter.UNREAD);
|
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) {
|
void onSearchQueryUpdated(String query) {
|
||||||
activeQuery = query;
|
activeQuery = query;
|
||||||
|
|
||||||
|
ConversationFilter filter = conversationFilter.getValue();
|
||||||
|
if (filter != ConversationFilter.OFF) {
|
||||||
|
contactSearchDebouncer.publish(() -> submitConversationSearch(query));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
contactSearchDebouncer.publish(() -> {
|
contactSearchDebouncer.publish(() -> {
|
||||||
searchRepository.queryThreads(query, result -> {
|
submitConversationSearch(query);
|
||||||
if (!result.getQuery().equals(activeQuery)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!activeSearchResult.getQuery().equals(activeQuery)) {
|
|
||||||
activeSearchResult = SearchResult.EMPTY;
|
|
||||||
}
|
|
||||||
|
|
||||||
activeSearchResult = activeSearchResult.merge(result);
|
|
||||||
searchResult.postValue(activeSearchResult);
|
|
||||||
});
|
|
||||||
|
|
||||||
searchRepository.queryContacts(query, result -> {
|
searchRepository.queryContacts(query, result -> {
|
||||||
if (!result.getQuery().equals(activeQuery)) {
|
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
|
@Override
|
||||||
protected void onCleared() {
|
protected void onCleared() {
|
||||||
invalidator.invalidate();
|
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.LayoutRes;
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.annotation.Px;
|
||||||
import androidx.appcompat.app.AppCompatActivity;
|
import androidx.appcompat.app.AppCompatActivity;
|
||||||
import androidx.appcompat.view.ContextThemeWrapper;
|
import androidx.appcompat.view.ContextThemeWrapper;
|
||||||
import androidx.core.view.ViewCompat;
|
import androidx.core.view.ViewCompat;
|
||||||
|
@ -54,6 +55,12 @@ public final class ViewUtil {
|
||||||
private 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) {
|
public static void focusAndMoveCursorToEndAndOpenKeyboard(@NonNull EditText input) {
|
||||||
int numberLength = input.getText().length();
|
int numberLength = input.getText().length();
|
||||||
input.setSelection(numberLength, numberLength);
|
input.setSelection(numberLength, numberLength);
|
||||||
|
|
|
@ -4,24 +4,30 @@
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
tools:parentTag="android.widget.FrameLayout">
|
tools:parentTag="android.widget.FrameLayout">
|
||||||
|
|
||||||
<TextView
|
<org.thoughtcrime.securesms.conversationlist.chatfilter.FilterCircleView
|
||||||
android:id="@+id/text"
|
android:id="@+id/filter_circle"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="match_parent" />
|
||||||
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" />
|
|
||||||
|
|
||||||
<ImageView
|
<com.google.android.material.chip.Chip
|
||||||
android:id="@+id/arrow"
|
android:id="@+id/filter_text"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="32dp"
|
||||||
android:layout_gravity="bottom|center_horizontal"
|
android:layout_gravity="center_horizontal|bottom"
|
||||||
android:layout_marginBottom="8dp"
|
android:layout_marginBottom="10dp"
|
||||||
android:importantForAccessibility="no"
|
android:enabled="false"
|
||||||
app:srcCompat="@drawable/ic_arrow_down"
|
android:text="@string/ChatFilter__filtered_by_unread"
|
||||||
app:tint="@color/signal_colorOnSurface" />
|
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>
|
</merge>
|
|
@ -50,16 +50,24 @@
|
||||||
android:id="@+id/recycler_coordinator_app_bar"
|
android:id="@+id/recycler_coordinator_app_bar"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
app:elevation="0dp"
|
||||||
app:expanded="false"
|
app:expanded="false"
|
||||||
app:layout_behavior="org.thoughtcrime.securesms.conversationlist.ConversationFilterBehavior">
|
app:layout_behavior="org.thoughtcrime.securesms.conversationlist.ConversationFilterBehavior">
|
||||||
|
|
||||||
<org.thoughtcrime.securesms.conversationlist.ConversationListFilterPullView
|
<com.google.android.material.appbar.CollapsingToolbarLayout
|
||||||
android:id="@+id/pull_view"
|
android:id="@+id/collapsing_toolbar"
|
||||||
|
app:layout_scrollFlags="scroll|enterAlwaysCollapsed|exitUntilCollapsed"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="80dp"
|
android:layout_height="wrap_content">
|
||||||
android:background="@color/signal_colorSurface1"
|
|
||||||
app:layout_scrollFlags="scroll|enterAlwaysCollapsed"
|
<org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationListFilterPullView
|
||||||
app:layout_scrollInterpolator="@android:anim/decelerate_interpolator" />
|
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>
|
</com.google.android.material.appbar.AppBarLayout>
|
||||||
|
|
||||||
|
|
|
@ -5453,4 +5453,8 @@
|
||||||
<string name="PaypalCompleteOrderBottomSheet__donate">Donate</string>
|
<string name="PaypalCompleteOrderBottomSheet__donate">Donate</string>
|
||||||
<string name="PaypalCompleteOrderBottomSheet__payment">Payment</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>
|
</resources>
|
||||||
|
|
Ładowanie…
Reference in New Issue