Reimplement contact chips with a recyclerview.

This commit is contained in:
Alex Hart 2022-06-29 09:10:08 -03:00 committed by Cody Henthorne
parent 4215b0391d
commit 7bd34d2b99
6 changed files with 191 additions and 118 deletions

View file

@ -18,7 +18,6 @@ package org.thoughtcrime.securesms;
import android.Manifest;
import android.animation.LayoutTransition;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.Intent;
@ -32,7 +31,6 @@ import android.view.View;
import android.view.ViewGroup;
import android.view.WindowManager;
import android.widget.Button;
import android.widget.HorizontalScrollView;
import android.widget.TextView;
import android.widget.Toast;
@ -43,6 +41,7 @@ import androidx.appcompat.app.AlertDialog;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.constraintlayout.widget.ConstraintSet;
import androidx.fragment.app.FragmentActivity;
import androidx.lifecycle.ViewModelProvider;
import androidx.loader.app.LoaderManager;
import androidx.loader.content.Loader;
import androidx.recyclerview.widget.DefaultItemAnimator;
@ -54,7 +53,6 @@ import androidx.transition.TransitionManager;
import com.annimon.stream.Collectors;
import com.annimon.stream.Stream;
import com.google.android.material.chip.ChipGroup;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import com.pnikosis.materialishprogress.ProgressWheel;
@ -62,7 +60,7 @@ import org.signal.core.util.concurrent.SimpleTask;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.components.RecyclerViewFastScroller;
import org.thoughtcrime.securesms.contacts.AbstractContactsCursorLoader;
import org.thoughtcrime.securesms.contacts.ContactChip;
import org.thoughtcrime.securesms.contacts.ContactChipViewModel;
import org.thoughtcrime.securesms.contacts.ContactSelectionListAdapter;
import org.thoughtcrime.securesms.contacts.ContactSelectionListItem;
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader;
@ -70,6 +68,7 @@ import org.thoughtcrime.securesms.contacts.ContactsCursorLoader.DisplayMode;
import org.thoughtcrime.securesms.contacts.HeaderAction;
import org.thoughtcrime.securesms.contacts.LetterHeaderDecoration;
import org.thoughtcrime.securesms.contacts.SelectedContact;
import org.thoughtcrime.securesms.contacts.SelectedContacts;
import org.thoughtcrime.securesms.contacts.selection.ContactSelectionArguments;
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
import org.thoughtcrime.securesms.groups.SelectionLimits;
@ -77,15 +76,17 @@ import org.thoughtcrime.securesms.groups.ui.GroupLimitDialog;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.recipients.LiveRecipient;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.sharing.ShareContact;
import org.thoughtcrime.securesms.util.LifecycleDisposable;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.UsernameUtil;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.adapter.FixedViewsAdapter;
import org.thoughtcrime.securesms.util.adapter.RecyclerViewConcatenateAdapterStickyHeader;
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter;
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModelList;
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
import java.io.IOException;
@ -95,6 +96,10 @@ import java.util.Optional;
import java.util.Set;
import java.util.function.Consumer;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.disposables.Disposable;
import kotlin.Unit;
/**
* Fragment for selecting a one or more contacts from a list.
*
@ -106,7 +111,7 @@ public final class ContactSelectionListFragment extends LoggingFragment
@SuppressWarnings("unused")
private static final String TAG = Log.tag(ContactSelectionListFragment.class);
private static final int CHIP_GROUP_EMPTY_CHILD_COUNT = 1;
private static final int CHIP_GROUP_EMPTY_CHILD_COUNT = 0;
private static final int CHIP_GROUP_REVEAL_DURATION_MS = 150;
public static final int NO_LIMIT = Integer.MAX_VALUE;
@ -134,10 +139,12 @@ public final class ContactSelectionListFragment extends LoggingFragment
private RecyclerView recyclerView;
private RecyclerViewFastScroller fastScroller;
private ContactSelectionListAdapter cursorRecyclerViewAdapter;
private ChipGroup chipGroup;
private HorizontalScrollView chipGroupScrollContainer;
private RecyclerView chipRecycler;
private OnSelectionLimitReachedListener onSelectionLimitReachedListener;
private AbstractContactsCursorLoaderFactoryProvider cursorFactoryProvider;
private MappingAdapter contactChipAdapter;
private ContactChipViewModel contactChipViewModel;
private LifecycleDisposable lifecycleDisposable;
private HeaderActionProvider headerActionProvider;
private TextView headerActionView;
@ -249,8 +256,7 @@ public final class ContactSelectionListFragment extends LoggingFragment
showContactsButton = view.findViewById(R.id.show_contacts_button);
showContactsDescription = view.findViewById(R.id.show_contacts_description);
showContactsProgress = view.findViewById(R.id.progress);
chipGroup = view.findViewById(R.id.chipGroup);
chipGroupScrollContainer = view.findViewById(R.id.chipGroupScrollContainer);
chipRecycler = view.findViewById(R.id.chipRecycler);
constraintLayout = view.findViewById(R.id.container);
headerActionView = view.findViewById(R.id.header_action);
@ -264,6 +270,18 @@ public final class ContactSelectionListFragment extends LoggingFragment
}
});
contactChipViewModel = new ViewModelProvider(this).get(ContactChipViewModel.class);
contactChipAdapter = new MappingAdapter();
lifecycleDisposable = new LifecycleDisposable();
lifecycleDisposable.bindTo(getViewLifecycleOwner());
SelectedContacts.register(contactChipAdapter, this::onChipCloseIconClicked);
chipRecycler.setAdapter(contactChipAdapter);
Disposable disposable = contactChipViewModel.getState().subscribe(this::handleSelectedContactsChanged);
lifecycleDisposable.add(disposable);
Intent intent = requireActivity().getIntent();
Bundle arguments = safeArguments();
@ -730,75 +748,22 @@ public final class ContactSelectionListFragment extends LoggingFragment
private void markContactUnselected(@NonNull SelectedContact selectedContact) {
cursorRecyclerViewAdapter.removeFromSelectedContacts(selectedContact);
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
removeChipForContact(selectedContact);
contactChipViewModel.remove(selectedContact);
if (onContactSelectedListener != null) {
onContactSelectedListener.onSelectionChanged();
}
}
private void removeChipForContact(@NonNull SelectedContact contact) {
for (int i = chipGroup.getChildCount() - 1; i >= 0; i--) {
View v = chipGroup.getChildAt(i);
if (v instanceof ContactChip && contact.matches(((ContactChip) v).getContact())) {
chipGroup.removeView(v);
}
}
private void handleSelectedContactsChanged(@NonNull List<SelectedContacts.Model> selectedContacts) {
contactChipAdapter.submitList(new MappingModelList(selectedContacts), this::smoothScrollChipsToEnd);
if (getChipCount() == 0) {
if (selectedContacts.isEmpty()) {
setChipGroupVisibility(ConstraintSet.GONE);
}
}
private void addChipForSelectedContact(@NonNull SelectedContact selectedContact) {
SimpleTask.run(getViewLifecycleOwner().getLifecycle(),
() -> Recipient.resolved(selectedContact.getOrCreateRecipientId(requireContext())),
resolved -> addChipForRecipient(resolved, selectedContact));
}
private void addChipForRecipient(@NonNull Recipient recipient, @NonNull SelectedContact selectedContact) {
final ContactChip chip = new ContactChip(requireContext());
if (getChipCount() == 0) {
} else {
setChipGroupVisibility(ConstraintSet.VISIBLE);
}
chip.setText(recipient.getShortDisplayName(requireContext()));
chip.setContact(selectedContact);
chip.setCloseIconVisible(true);
chip.setOnCloseIconClickListener(view -> {
markContactUnselected(selectedContact);
if (onContactSelectedListener != null) {
onContactSelectedListener.onContactDeselected(Optional.of(recipient.getId()), recipient.getE164().orElse(null));
}
});
chipGroup.getLayoutTransition().addTransitionListener(new LayoutTransition.TransitionListener() {
@Override
public void startTransition(LayoutTransition transition, ViewGroup container, View view, int transitionType) {
}
@Override
public void endTransition(LayoutTransition transition, ViewGroup container, View view, int transitionType) {
if (getView() == null || !requireView().isAttachedToWindow()) {
Log.w(TAG, "Fragment's view was detached before the animation completed.");
return;
}
if (view == chip && transitionType == LayoutTransition.APPEARING) {
chipGroup.getLayoutTransition().removeTransitionListener(this);
registerChipRecipientObserver(chip, recipient.live());
chipGroup.post(ContactSelectionListFragment.this::smoothScrollChipsToEnd);
}
}
});
chip.setAvatar(glideRequests, recipient, () -> addChip(chip));
}
private void addChip(@NonNull ContactChip chip) {
chipGroup.addView(chip);
if (selectionWarningLimitReachedExactly()) {
if (onSelectionLimitReachedListener != null) {
onSelectionLimitReachedListener.onSuggestedLimitReached(selectionLimit.getRecommendedLimit());
@ -808,21 +773,25 @@ public final class ContactSelectionListFragment extends LoggingFragment
}
}
private int getChipCount() {
int count = chipGroup.getChildCount() - CHIP_GROUP_EMPTY_CHILD_COUNT;
if (count < 0) throw new AssertionError();
return count;
private void addChipForSelectedContact(@NonNull SelectedContact selectedContact) {
SimpleTask.run(getViewLifecycleOwner().getLifecycle(),
() -> Recipient.resolved(selectedContact.getOrCreateRecipientId(requireContext())),
resolved -> contactChipViewModel.add(selectedContact));
}
private void registerChipRecipientObserver(@NonNull ContactChip chip, @Nullable LiveRecipient recipient) {
if (recipient != null) {
recipient.observe(getViewLifecycleOwner(), resolved -> {
if (chip.isAttachedToWindow()) {
chip.setAvatar(glideRequests, resolved, null);
chip.setText(resolved.getShortDisplayName(chip.getContext()));
}
});
private Unit onChipCloseIconClicked(SelectedContacts.Model model) {
markContactUnselected(model.getSelectedContact());
if (onContactSelectedListener != null) {
onContactSelectedListener.onContactDeselected(Optional.of(model.getRecipient().getId()), model.getRecipient().getE164().orElse(null));
}
return Unit.INSTANCE;
}
private int getChipCount() {
int count = contactChipViewModel.getCount() - CHIP_GROUP_EMPTY_CHILD_COUNT;
if (count < 0) throw new AssertionError();
return count;
}
private void setChipGroupVisibility(int visibility) {
@ -838,7 +807,7 @@ public final class ContactSelectionListFragment extends LoggingFragment
ConstraintSet constraintSet = new ConstraintSet();
constraintSet.clone(constraintLayout);
constraintSet.setVisibility(R.id.chipGroupScrollContainer, visibility);
constraintSet.setVisibility(R.id.chipRecycler, visibility);
constraintSet.applyTo(constraintLayout);
}
@ -847,8 +816,8 @@ public final class ContactSelectionListFragment extends LoggingFragment
}
private void smoothScrollChipsToEnd() {
int x = ViewUtil.isLtr(chipGroupScrollContainer) ? chipGroup.getWidth() : 0;
chipGroupScrollContainer.smoothScrollTo(x, 0);
int x = ViewUtil.isLtr(chipRecycler) ? chipRecycler.getWidth() : 0;
chipRecycler.smoothScrollBy(x, 0);
}
public interface OnContactSelectedListener {

View file

@ -0,0 +1,72 @@
package org.thoughtcrime.securesms.contacts
import androidx.lifecycle.ViewModel
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.BackpressureStrategy
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.schedulers.Schedulers
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.rx.RxStore
/**
* ViewModel expressly for displaying the current state of the contact chips
* in the contact selection fragment.
*/
class ContactChipViewModel : ViewModel() {
private val store = RxStore(emptyList<SelectedContacts.Model>())
val state: Flowable<List<SelectedContacts.Model>> = store.stateFlowable
.distinctUntilChanged()
.observeOn(AndroidSchedulers.mainThread())
val count = store.state.size
private val disposables = CompositeDisposable()
private val disposableMap: MutableMap<RecipientId, Disposable> = mutableMapOf()
override fun onCleared() {
disposables.clear()
disposableMap.values.forEach { it.dispose() }
disposableMap.clear()
}
fun add(selectedContact: SelectedContact) {
disposables += getOrCreateRecipientId(selectedContact).map { Recipient.resolved(it) }.observeOn(Schedulers.io()).subscribe { recipient ->
store.update { it + SelectedContacts.Model(selectedContact, recipient) }
disposableMap[recipient.id]?.dispose()
disposableMap[recipient.id] = store.update(recipient.live().asObservable().toFlowable(BackpressureStrategy.LATEST)) { changedRecipient, state ->
val index = state.indexOfFirst { it.selectedContact.matches(selectedContact) }
when {
index == 0 -> {
listOf(SelectedContacts.Model(selectedContact, changedRecipient)) + state.drop(index + 1)
}
index > 0 -> {
state.take(index) + SelectedContacts.Model(selectedContact, changedRecipient) + state.drop(index + 1)
}
else -> {
state
}
}
}
}
}
fun remove(selectedContact: SelectedContact) {
store.update { list ->
list.filterNot { it.selectedContact.matches(selectedContact) }
}
}
private fun getOrCreateRecipientId(selectedContact: SelectedContact): Single<RecipientId> {
return Single.fromCallable {
selectedContact.getOrCreateRecipientId(ApplicationDependencies.getApplication())
}
}
}

View file

@ -0,0 +1,42 @@
package org.thoughtcrime.securesms.contacts
import android.view.View
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
object SelectedContacts {
@JvmStatic
fun register(adapter: MappingAdapter, onCloseIconClicked: (Model) -> Unit) {
adapter.registerFactory(Model::class.java, LayoutFactory({ ViewHolder(it, onCloseIconClicked) }, R.layout.contact_selection_list_chip))
}
class Model(val selectedContact: SelectedContact, val recipient: Recipient) : MappingModel<Model> {
override fun areItemsTheSame(newItem: Model): Boolean {
return newItem.selectedContact.matches(selectedContact) && recipient == newItem.recipient
}
override fun areContentsTheSame(newItem: Model): Boolean {
return areItemsTheSame(newItem) && recipient.hasSameContent(newItem.recipient)
}
}
private class ViewHolder(itemView: View, private val onCloseIconClicked: (Model) -> Unit) : MappingViewHolder<Model>(itemView) {
private val chip: ContactChip = itemView.findViewById(R.id.contact_chip)
override fun bind(model: Model) {
chip.text = model.recipient.getShortDisplayName(context)
chip.setContact(model.selectedContact)
chip.isCloseIconVisible = true
chip.setOnCloseIconClickListener {
onCloseIconClicked(model)
}
chip.setAvatar(GlideApp.with(itemView), model.recipient, null)
}
}
}

View file

@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.util.rx
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Scheduler
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.processors.BehaviorProcessor
import io.reactivex.rxjava3.schedulers.Schedulers
import io.reactivex.rxjava3.subjects.PublishSubject
@ -31,4 +32,10 @@ class RxStore<T : Any>(
fun update(transformer: (T) -> T) {
actionSubject.onNext(transformer)
}
fun <U> update(flowable: Flowable<U>, transformer: (U, T) -> T): Disposable {
return flowable.subscribe {
actionSubject.onNext { t -> transformer(it, t) }
}
}
}

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<org.thoughtcrime.securesms.contacts.ContactChip xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/contact_chip"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="12dp" />

View file

@ -14,7 +14,7 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/chipGroupScrollContainer">
app:layout_constraintTop_toBottomOf="@+id/chipRecycler">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view"
@ -44,7 +44,7 @@
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/chipGroupScrollContainer"
app:layout_constraintTop_toBottomOf="@+id/chipRecycler"
tools:visibility="visible" />
<LinearLayout
@ -58,12 +58,12 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/chipGroupScrollContainer"
app:layout_constraintTop_toBottomOf="@+id/chipRecycler"
tools:visibility="visible">
<com.google.android.material.button.MaterialButton
style="@style/Signal.Widget.Button.Large.Primary"
android:id="@+id/show_contacts_button"
style="@style/Signal.Widget.Button.Large.Primary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
@ -112,45 +112,22 @@
</LinearLayout>
<HorizontalScrollView
android:id="@+id/chipGroupScrollContainer"
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/chipRecycler"
android:layout_width="match_parent"
android:layout_height="56dp"
android:layout_height="wrap_content"
android:clipChildren="false"
android:clipToPadding="false"
android:minHeight="48sp"
android:orientation="horizontal"
android:paddingStart="@dimen/dsl_settings_gutter"
android:paddingEnd="@dimen/dsl_settings_gutter"
android:scrollbars="none"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_constraintTop_toTopOf="parent"
tools:visibility="visible">
<FrameLayout
android:layout_width="wrap_content"
android:layout_height="match_parent">
<com.google.android.material.chip.ChipGroup
android:id="@+id/chipGroup"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_marginEnd="96dp"
android:animateLayoutChanges="true"
app:singleLine="true">
<com.google.android.material.chip.Chip
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
tools:text="Example"
tools:visibility="visible" />
</com.google.android.material.chip.ChipGroup>
</FrameLayout>
</HorizontalScrollView>
tools:listitem="@layout/contact_selection_list_chip"
tools:visibility="visible" />
<TextView
android:id="@+id/header_action"