Switch from ListView to RecyclerView

Signed-off-by: Eric Kuck <eric@bluelinelabs.com>
This commit is contained in:
Eric Kuck 2018-07-06 16:02:48 -05:00 committed by Jason A. Donenfeld
parent 2c7203ab8d
commit b37b48b8dc
6 changed files with 107 additions and 237 deletions

View File

@ -13,10 +13,10 @@ import android.support.v7.widget.LinearLayoutManager;
import android.support.v7.widget.RecyclerView; import android.support.v7.widget.RecyclerView;
import android.text.InputFilter; import android.text.InputFilter;
import android.widget.LinearLayout; import android.widget.LinearLayout;
import android.widget.ListView;
import android.widget.TextView; import android.widget.TextView;
import com.wireguard.android.R; import com.wireguard.android.R;
import com.wireguard.android.databinding.ObservableKeyedRecyclerViewAdapter.RowConfigurationHandler;
import com.wireguard.android.util.ObservableKeyedList; import com.wireguard.android.util.ObservableKeyedList;
import com.wireguard.android.widget.ToggleSwitch; import com.wireguard.android.widget.ToggleSwitch;
import com.wireguard.android.widget.ToggleSwitch.OnBeforeCheckedChangeListener; import com.wireguard.android.widget.ToggleSwitch.OnBeforeCheckedChangeListener;
@ -67,37 +67,11 @@ public final class BindingAdapters {
listener.setList(newList); listener.setList(newList);
} }
@BindingAdapter({"items", "layout"}) @BindingAdapter(requireAll = false, value = {"items", "layout", "configurationHandler"})
public static <K, E extends Keyed<? extends K>>
void setItems(final ListView view,
final ObservableKeyedList<K, E> oldList, final int oldLayoutId,
final ObservableKeyedList<K, E> newList, final int newLayoutId) {
if (oldList == newList && oldLayoutId == newLayoutId)
return;
// The ListAdapter interface is not generic, so this cannot be checked.
@SuppressWarnings("unchecked") ObservableKeyedListAdapter<K, E> adapter =
(ObservableKeyedListAdapter<K, E>) view.getAdapter();
// If the layout changes, any existing adapter must be replaced.
if (adapter != null && oldList != null && oldLayoutId != newLayoutId) {
adapter.setList(null);
adapter = null;
}
// Avoid setting an adapter when there is no new list or layout.
if (newList == null || newLayoutId == 0)
return;
if (adapter == null) {
adapter = new ObservableKeyedListAdapter<>(view.getContext(), newLayoutId, newList);
view.setAdapter(adapter);
}
// Either the list changed, or this is an entirely new listener because the layout changed.
adapter.setList(newList);
}
@BindingAdapter({"items", "layout"})
public static <K, E extends Keyed<? extends K>> public static <K, E extends Keyed<? extends K>>
void setItems(final RecyclerView view, void setItems(final RecyclerView view,
final ObservableKeyedList<K, E> oldList, final int oldLayoutId, final ObservableKeyedList<K, E> oldList, final int oldLayoutId, final RowConfigurationHandler oldRowConfigurationHandler,
final ObservableKeyedList<K, E> newList, final int newLayoutId) { final ObservableKeyedList<K, E> newList, final int newLayoutId, final RowConfigurationHandler newRowConfigurationHandler) {
if (view.getLayoutManager() == null) if (view.getLayoutManager() == null)
view.setLayoutManager(new LinearLayoutManager(view.getContext(), RecyclerView.VERTICAL, false)); view.setLayoutManager(new LinearLayoutManager(view.getContext(), RecyclerView.VERTICAL, false));
@ -118,6 +92,8 @@ public final class BindingAdapters {
adapter = new ObservableKeyedRecyclerViewAdapter<>(view.getContext(), newLayoutId, newList); adapter = new ObservableKeyedRecyclerViewAdapter<>(view.getContext(), newLayoutId, newList);
view.setAdapter(adapter); view.setAdapter(adapter);
} }
adapter.setRowConfigurationHandler(newRowConfigurationHandler);
// Either the list changed, or this is an entirely new listener because the layout changed. // Either the list changed, or this is an entirely new listener because the layout changed.
adapter.setList(newList); adapter.setList(newList);
} }

View File

@ -1,133 +0,0 @@
/*
* Copyright © 2018 Samuel Holland <samuel@sholland.org>
* Copyright © 2018 Jason A. Donenfeld <Jason@zx2c4.com>. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/
package com.wireguard.android.databinding;
import android.content.Context;
import android.databinding.DataBindingUtil;
import android.databinding.ObservableList;
import android.databinding.ViewDataBinding;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.BaseAdapter;
import com.wireguard.android.BR;
import com.wireguard.util.Keyed;
import com.wireguard.android.util.ObservableKeyedList;
import java.lang.ref.WeakReference;
/**
* A generic {@code ListAdapter} backed by a {@code ObservableKeyedList}.
*/
class ObservableKeyedListAdapter<K, E extends Keyed<? extends K>> extends BaseAdapter {
private final OnListChangedCallback<E> callback = new OnListChangedCallback<>(this);
private final int layoutId;
private final LayoutInflater layoutInflater;
private ObservableKeyedList<K, E> list;
ObservableKeyedListAdapter(final Context context, final int layoutId,
final ObservableKeyedList<K, E> list) {
this.layoutId = layoutId;
layoutInflater = LayoutInflater.from(context);
setList(list);
}
@Override
public int getCount() {
return list != null ? list.size() : 0;
}
@Override
public E getItem(final int position) {
if (list == null || position < 0 || position >= list.size())
return null;
return list.get(position);
}
@Override
public long getItemId(final int position) {
final K key = getKey(position);
return key != null ? key.hashCode() : -1;
}
private K getKey(final int position) {
final E item = getItem(position);
return item != null ? item.getKey() : null;
}
@Override
public View getView(final int position, final View convertView, final ViewGroup parent) {
ViewDataBinding binding = DataBindingUtil.getBinding(convertView);
if (binding == null)
binding = DataBindingUtil.inflate(layoutInflater, layoutId, parent, false);
binding.setVariable(BR.collection, list);
binding.setVariable(BR.key, getKey(position));
binding.setVariable(BR.item, getItem(position));
binding.executePendingBindings();
return binding.getRoot();
}
@Override
public boolean hasStableIds() {
return true;
}
void setList(final ObservableKeyedList<K, E> newList) {
if (list != null)
list.removeOnListChangedCallback(callback);
list = newList;
if (list != null) {
list.addOnListChangedCallback(callback);
}
notifyDataSetChanged();
}
private static final class OnListChangedCallback<E extends Keyed<?>>
extends ObservableList.OnListChangedCallback<ObservableList<E>> {
private final WeakReference<ObservableKeyedListAdapter<?, E>> weakAdapter;
private OnListChangedCallback(final ObservableKeyedListAdapter<?, E> adapter) {
weakAdapter = new WeakReference<>(adapter);
}
@Override
public void onChanged(final ObservableList<E> sender) {
final ObservableKeyedListAdapter adapter = weakAdapter.get();
if (adapter != null)
adapter.notifyDataSetChanged();
else
sender.removeOnListChangedCallback(this);
}
@Override
public void onItemRangeChanged(final ObservableList<E> sender, final int positionStart,
final int itemCount) {
onChanged(sender);
}
@Override
public void onItemRangeInserted(final ObservableList<E> sender, final int positionStart,
final int itemCount) {
onChanged(sender);
}
@Override
public void onItemRangeMoved(final ObservableList<E> sender, final int fromPosition,
final int toPosition, final int itemCount) {
onChanged(sender);
}
@Override
public void onItemRangeRemoved(final ObservableList<E> sender, final int positionStart,
final int itemCount) {
onChanged(sender);
}
}
}

View File

@ -14,6 +14,7 @@ import android.support.annotation.NonNull;
import android.support.v7.widget.RecyclerView; import android.support.v7.widget.RecyclerView;
import android.support.v7.widget.RecyclerView.Adapter; import android.support.v7.widget.RecyclerView.Adapter;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import com.wireguard.android.BR; import com.wireguard.android.BR;
@ -26,12 +27,13 @@ import java.lang.ref.WeakReference;
* A generic {@code RecyclerView.Adapter} backed by a {@code ObservableKeyedList}. * A generic {@code RecyclerView.Adapter} backed by a {@code ObservableKeyedList}.
*/ */
class ObservableKeyedRecyclerViewAdapter<K, E extends Keyed<? extends K>> extends Adapter<ObservableKeyedRecyclerViewAdapter.ViewHolder> { public class ObservableKeyedRecyclerViewAdapter<K, E extends Keyed<? extends K>> extends Adapter<ObservableKeyedRecyclerViewAdapter.ViewHolder> {
private final OnListChangedCallback<E> callback = new OnListChangedCallback<>(this); private final OnListChangedCallback<E> callback = new OnListChangedCallback<>(this);
private final int layoutId; private final int layoutId;
private final LayoutInflater layoutInflater; private final LayoutInflater layoutInflater;
private ObservableKeyedList<K, E> list; private ObservableKeyedList<K, E> list;
private RowConfigurationHandler rowConfigurationHandler;
ObservableKeyedRecyclerViewAdapter(final Context context, final int layoutId, ObservableKeyedRecyclerViewAdapter(final Context context, final int layoutId,
final ObservableKeyedList<K, E> list) { final ObservableKeyedList<K, E> list) {
@ -67,12 +69,17 @@ class ObservableKeyedRecyclerViewAdapter<K, E extends Keyed<? extends K>> extend
return new ViewHolder(DataBindingUtil.inflate(layoutInflater, layoutId, parent, false)); return new ViewHolder(DataBindingUtil.inflate(layoutInflater, layoutId, parent, false));
} }
@SuppressWarnings("unchecked")
@Override @Override
public void onBindViewHolder(@NonNull final ViewHolder holder, final int position) { public void onBindViewHolder(@NonNull final ViewHolder holder, final int position) {
holder.binding.setVariable(BR.collection, list); holder.binding.setVariable(BR.collection, list);
holder.binding.setVariable(BR.key, getKey(position)); holder.binding.setVariable(BR.key, getKey(position));
holder.binding.setVariable(BR.item, getItem(position)); holder.binding.setVariable(BR.item, getItem(position));
holder.binding.executePendingBindings(); holder.binding.executePendingBindings();
if (rowConfigurationHandler != null) {
rowConfigurationHandler.onConfigureRow(holder.binding.getRoot(), getItem(position), position);
}
} }
void setList(final ObservableKeyedList<K, E> newList) { void setList(final ObservableKeyedList<K, E> newList) {
@ -85,6 +92,10 @@ class ObservableKeyedRecyclerViewAdapter<K, E extends Keyed<? extends K>> extend
notifyDataSetChanged(); notifyDataSetChanged();
} }
void setRowConfigurationHandler(final RowConfigurationHandler rowConfigurationHandler) {
this.rowConfigurationHandler = rowConfigurationHandler;
}
private static final class OnListChangedCallback<E extends Keyed<?>> private static final class OnListChangedCallback<E extends Keyed<?>>
extends ObservableList.OnListChangedCallback<ObservableList<E>> { extends ObservableList.OnListChangedCallback<ObservableList<E>> {
@ -138,4 +149,8 @@ class ObservableKeyedRecyclerViewAdapter<K, E extends Keyed<? extends K>> extend
} }
} }
public interface RowConfigurationHandler<T> {
void onConfigureRow(View view, T item, int position);
}
} }

View File

@ -16,26 +16,21 @@ import android.net.Uri;
import android.os.Bundle; import android.os.Bundle;
import android.provider.OpenableColumns; import android.provider.OpenableColumns;
import android.support.annotation.NonNull; import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.design.widget.Snackbar; import android.support.design.widget.Snackbar;
import android.support.v7.app.AppCompatActivity;
import android.support.v7.view.ActionMode;
import android.util.Log; import android.util.Log;
import android.util.SparseBooleanArray;
import android.view.ActionMode;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.Menu; import android.view.Menu;
import android.view.MenuItem; import android.view.MenuItem;
import android.view.MotionEvent;
import android.view.View; import android.view.View;
import android.view.View.OnTouchListener;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.widget.AbsListView;
import android.widget.AbsListView.MultiChoiceModeListener;
import android.widget.AdapterView;
import android.widget.AdapterView.OnItemClickListener;
import android.widget.AdapterView.OnItemLongClickListener;
import com.wireguard.android.Application; import com.wireguard.android.Application;
import com.wireguard.android.R; import com.wireguard.android.R;
import com.wireguard.android.activity.TunnelCreatorActivity; import com.wireguard.android.activity.TunnelCreatorActivity;
import com.wireguard.android.databinding.ObservableKeyedRecyclerViewAdapter;
import com.wireguard.android.databinding.TunnelListFragmentBinding; import com.wireguard.android.databinding.TunnelListFragmentBinding;
import com.wireguard.android.model.Tunnel; import com.wireguard.android.model.Tunnel;
import com.wireguard.android.util.ExceptionLoggers; import com.wireguard.android.util.ExceptionLoggers;
@ -47,13 +42,13 @@ import java.nio.charset.StandardCharsets;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collection; import java.util.Collection;
import java.util.Collections; import java.util.Collections;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Set;
import java.util.zip.ZipEntry; import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream; import java.util.zip.ZipInputStream;
import java9.util.concurrent.CompletableFuture; import java9.util.concurrent.CompletableFuture;
import java9.util.stream.Collectors;
import java9.util.stream.IntStream;
import java9.util.stream.StreamSupport; import java9.util.stream.StreamSupport;
/** /**
@ -64,8 +59,7 @@ public class TunnelListFragment extends BaseFragment {
private static final int REQUEST_IMPORT = 1; private static final int REQUEST_IMPORT = 1;
private static final String TAG = "WireGuard/" + TunnelListFragment.class.getSimpleName(); private static final String TAG = "WireGuard/" + TunnelListFragment.class.getSimpleName();
private final MultiChoiceModeListener actionModeListener = new ActionModeListener(); private final ActionModeListener actionModeListener = new ActionModeListener();
private final ListViewCallbacks listViewCallbacks = new ListViewCallbacks();
private ActionMode actionMode; private ActionMode actionMode;
private TunnelListFragmentBinding binding; private TunnelListFragmentBinding binding;
@ -182,20 +176,19 @@ public class TunnelListFragment extends BaseFragment {
} }
} }
@Override @SuppressLint("ClickableViewAccessibility")
public void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
}
@Override @Override
public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container, public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container,
final Bundle savedInstanceState) { final Bundle savedInstanceState) {
super.onCreateView(inflater, container, savedInstanceState); super.onCreateView(inflater, container, savedInstanceState);
binding = TunnelListFragmentBinding.inflate(inflater, container, false); binding = TunnelListFragmentBinding.inflate(inflater, container, false);
binding.tunnelList.setMultiChoiceModeListener(actionModeListener);
binding.tunnelList.setOnItemClickListener(listViewCallbacks); binding.tunnelList.setOnTouchListener((view, motionEvent) -> {
binding.tunnelList.setOnItemLongClickListener(listViewCallbacks); if (binding != null) {
binding.tunnelList.setOnTouchListener(listViewCallbacks); binding.createMenu.collapse();
}
return false;
});
binding.executePendingBindings(); binding.executePendingBindings();
return binding.getRoot(); return binding.getRoot();
} }
@ -276,38 +269,54 @@ public class TunnelListFragment extends BaseFragment {
super.onViewStateRestored(savedInstanceState); super.onViewStateRestored(savedInstanceState);
binding.setFragment(this); binding.setFragment(this);
binding.setTunnels(Application.getTunnelManager().getTunnels()); binding.setTunnels(Application.getTunnelManager().getTunnels());
binding.setRowConfigurationHandler(new ObservableKeyedRecyclerViewAdapter.RowConfigurationHandler<Tunnel>() {
@Override
public void onConfigureRow(View view, Tunnel tunnel, int position) {
view.setOnClickListener(clicked -> {
if (actionMode == null) {
setSelectedTunnel(tunnel);
} else {
actionModeListener.toggleItemChecked(position);
}
});
view.setOnLongClickListener(clicked -> {
actionModeListener.toggleItemChecked(position);
return true;
});
view.setActivated(actionModeListener.checkedItems.contains(position));
}
});
} }
private final class ActionModeListener implements MultiChoiceModeListener { private final class ActionModeListener implements ActionMode.Callback {
private final Set<Integer> checkedItems = new HashSet<>();
private Resources resources; private Resources resources;
private AbsListView tunnelList;
private IntStream getCheckedPositions() {
final SparseBooleanArray checkedItemPositions = tunnelList.getCheckedItemPositions();
return IntStream.range(0, checkedItemPositions.size())
.filter(checkedItemPositions::valueAt)
.map(checkedItemPositions::keyAt);
}
@Override @Override
public boolean onActionItemClicked(final ActionMode mode, final MenuItem item) { public boolean onActionItemClicked(final ActionMode mode, final MenuItem item) {
switch (item.getItemId()) { switch (item.getItemId()) {
case R.id.menu_action_delete: case R.id.menu_action_delete:
// Must operate in two steps: positions change once we start deleting things. List<Tunnel> tunnelsToDelete = new ArrayList<>();
final List<Tunnel> tunnelsToDelete = getCheckedPositions() for (Integer position : checkedItems) {
.mapToObj(pos -> (Tunnel) tunnelList.getItemAtPosition(pos)) tunnelsToDelete.add(Application.getTunnelManager().getTunnels().get(position));
.collect(Collectors.toList()); }
final CompletableFuture[] futures = StreamSupport.stream(tunnelsToDelete) final CompletableFuture[] futures = StreamSupport.stream(tunnelsToDelete)
.map(Tunnel::delete) .map(Tunnel::delete)
.toArray(CompletableFuture[]::new); .toArray(CompletableFuture[]::new);
CompletableFuture.allOf(futures) CompletableFuture.allOf(futures)
.thenApply(x -> futures.length) .thenApply(x -> futures.length)
.whenComplete(TunnelListFragment.this::onTunnelDeletionFinished); .whenComplete(TunnelListFragment.this::onTunnelDeletionFinished);
checkedItems.clear();
mode.finish(); mode.finish();
return true; return true;
case R.id.menu_action_select_all: case R.id.menu_action_select_all:
for (int i = 0; i < tunnelList.getAdapter().getCount(); ++i) for (int i = 0; i < Application.getTunnelManager().getTunnels().size(); ++i) {
tunnelList.setItemChecked(i, true); setItemChecked(i, true);
}
return true; return true;
default: default:
return false; return false;
@ -317,9 +326,9 @@ public class TunnelListFragment extends BaseFragment {
@Override @Override
public boolean onCreateActionMode(final ActionMode mode, final Menu menu) { public boolean onCreateActionMode(final ActionMode mode, final Menu menu) {
actionMode = mode; actionMode = mode;
if (getActivity() != null) if (getActivity() != null) {
resources = getActivity().getResources(); resources = getActivity().getResources();
tunnelList = binding.tunnelList; }
mode.getMenuInflater().inflate(R.menu.tunnel_list_action_mode, menu); mode.getMenuInflater().inflate(R.menu.tunnel_list_action_mode, menu);
return true; return true;
} }
@ -328,12 +337,31 @@ public class TunnelListFragment extends BaseFragment {
public void onDestroyActionMode(final ActionMode mode) { public void onDestroyActionMode(final ActionMode mode) {
actionMode = null; actionMode = null;
resources = null; resources = null;
checkedItems.clear();
binding.tunnelList.getAdapter().notifyDataSetChanged();
} }
@Override void toggleItemChecked(int position) {
public void onItemCheckedStateChanged(final ActionMode mode, final int position, setItemChecked(position, !checkedItems.contains(position));
final long id, final boolean checked) { }
updateTitle(mode);
void setItemChecked(int position, boolean checked) {
if (checked) {
checkedItems.add(position);
} else {
checkedItems.remove(position);
}
if (actionMode == null && !checkedItems.isEmpty() && getActivity() != null) {
((AppCompatActivity) getActivity()).startSupportActionMode(this);
} else if (actionMode != null && checkedItems.isEmpty()) {
actionMode.finish();
}
binding.tunnelList.getAdapter().notifyItemChanged(position);
updateTitle(actionMode);
} }
@Override @Override
@ -342,8 +370,12 @@ public class TunnelListFragment extends BaseFragment {
return false; return false;
} }
private void updateTitle(final ActionMode mode) { private void updateTitle(@Nullable final ActionMode mode) {
final int count = (int) getCheckedPositions().count(); if (mode == null) {
return;
}
final int count = checkedItems.size();
if (count == 0) { if (count == 0) {
mode.setTitle(""); mode.setTitle("");
} else { } else {
@ -352,30 +384,4 @@ public class TunnelListFragment extends BaseFragment {
} }
} }
private final class ListViewCallbacks
implements OnItemClickListener, OnItemLongClickListener, OnTouchListener {
@Override
public void onItemClick(final AdapterView<?> parent, final View view,
final int position, final long id) {
setSelectedTunnel((Tunnel) parent.getItemAtPosition(position));
}
@Override
public boolean onItemLongClick(final AdapterView<?> parent, final View view,
final int position, final long id) {
if (actionMode != null)
return false;
if (binding != null)
binding.tunnelList.setItemChecked(position, true);
return true;
}
@Override
@SuppressLint("ClickableViewAccessibility")
public boolean onTouch(final View view, final MotionEvent motionEvent) {
if (binding != null)
binding.createMenu.collapse();
return false;
}
}
} }

View File

@ -2,4 +2,5 @@
<ripple xmlns:android="http://schemas.android.com/apk/res/android" <ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="@color/list_item_ripple"> <!-- TODO(msf): themeify this --> android:color="@color/list_item_ripple"> <!-- TODO(msf): themeify this -->
<item android:drawable="@drawable/list_item_background" /> <item android:drawable="@drawable/list_item_background" />
<item android:id="@android:id/mask" android:drawable="@android:color/white" />
</ripple> </ripple>

View File

@ -10,6 +10,10 @@
name="fragment" name="fragment"
type="com.wireguard.android.fragment.TunnelListFragment" /> type="com.wireguard.android.fragment.TunnelListFragment" />
<variable
name="rowConfigurationHandler"
type="com.wireguard.android.databinding.ObservableKeyedRecyclerViewAdapter.RowConfigurationHandler" />
<variable <variable
name="tunnels" name="tunnels"
type="com.wireguard.android.util.ObservableKeyedList&lt;String, Tunnel&gt;" /> type="com.wireguard.android.util.ObservableKeyedList&lt;String, Tunnel&gt;" />
@ -21,13 +25,14 @@
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="?android:attr/colorBackground"> android:background="?android:attr/colorBackground">
<ListView <android.support.v7.widget.RecyclerView
android:id="@+id/tunnel_list" android:id="@+id/tunnel_list"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:choiceMode="multipleChoiceModal" android:choiceMode="multipleChoiceModal"
app:items="@{tunnels}" app:items="@{tunnels}"
app:layout="@{@layout/tunnel_list_item}" /> app:layout="@{@layout/tunnel_list_item}"
app:configurationHandler="@{rowConfigurationHandler}" />
<com.wireguard.android.widget.fab.FloatingActionsMenu <com.wireguard.android.widget.fab.FloatingActionsMenu
android:id="@+id/create_menu" android:id="@+id/create_menu"