“手機上的多選很難操作”,我們的設計師Vitaly Rubtsov如是說。大多數應用中的多選方案 -Telegram, Apple Music, Spotify等等- 通常都不是那么靈活,用起來也不舒服。
比如,當你在Apple Music中創建自己的播放列表時,如果不切換屏幕或者無盡的滾動一遍被選中的歌曲,你都不清楚自己選擇了哪些歌曲。
如果我們想使用篩選功能事情就變得更糟糕了。應用了一個篩選條件之后,列表的結構可能會發生改變,選中的item也許根本就不會顯示。Vitaly決定使用他自己的多選概念設計(最早發布在Dribbble)來解決這個問題。
他的想法非常聰明:把屏幕分成兩部分,就如Vitaly解釋的那樣,你總是能“看見和管理已經選擇的項目,而不需要離開當前的視圖”。而篩選只應用在主列表,不會影響已經選擇的item列表。
那時我明白了必須千方百計把Vitaly的多選概念設計實現出來;所以我幾乎立即就開始了編寫這個控件的工作。現在讓我們來看看這個安卓的多選動畫是如何誕生的。
實現
這個控件有一個帶了兩個RecyclerView的ViewPager,我們可以通過重寫getPageWidth方法返回一個0到1之間的浮點數來讓ViewPager的頁面小于屏幕。
一個具有兩個頁面的ViewPager,每個頁面包含一個RecyclerView。未被選擇的item在左邊的列表。選中的item在右邊的列表。比如,如果你點擊了一個未被選擇的item,將發生以下事情:
- 被點擊的item從未被選中的item列表中移除并被添加到包含了兩個列表的容器中。
- 選中的item的位置是固定的。(未被選中的列表總是按照字母順序排列。選中列表按照被選擇的先后順序排列)
- 一個隱藏的item被添加到選中列表中。
- 對被點擊的item執行過渡動畫。
- 刪除被點擊的item并顯示選中列表中隱藏的item。
這個過程中最技巧性的部分是把view從layout manager移除;否則layout manager 會嘗試回收它,因為已經從RecyclerView刪除了這個view,所以這會導致錯誤:
sourceRecycler.layoutManager.removeViewAt(position)
技術棧
我們選擇Kotlin語言來做這個工作。和Java相比,Kotlin最主要的優點是其簡明的語法和不會出現NullPointerException之類的崩潰。這里是我在實現這個庫的過程中,Kotlin的這些特性給我帶來了方便:
- 1.擴展函數
Kotlin的擴展函數功能使得我們可以為現有的類添加新的函數,而不用修改原來的類。
就拿安卓的View來說。通常你需要把一個view從其父親那里移除并掛載到新的view上。
從view的父親移除自己:
fun View.removeFromParent() {
val parent = this.parent
if (parent is ViewGroup) {
parent.removeView(this)
}
}
定義了上面的方法之后,你就可以在項目的任何地方這樣調用它了:
view.removeFromParent()
你甚至可以直接寫一個方法做完所有事情把一個view從當前父親那里移除并掛載到新的view上:
view.attachTo(newParent)
另一個好處是你可以添加setScaleXY方法。很少見到使用了setScaleX而不用setScaleY的情況,所以為什么不用一個方法設置兩個Scale呢?讓我們做一個這樣的函數:
fun View.setScaleXY(scale: Float) {
scaleX = scale
scaleY = scale
}
你可以在library源碼的 Extensions.kt文件中找到更多使用擴展函數的例子。
- 2.Null safety
Kotlin的null safety特性是一個規則改變者 ‘?.’操作符和 ‘.’ 一樣的意思只是如果對象是null而被調用的話不會拋出NullPointerException,而是返回null:
var targetView: View? = targetRecycler.findViewHolderForAdapterPosition(prev)?.itemView
上面的代碼中,即使findViewHolderForAdapterPosition返回null也不會崩潰。
- 3.Collections
Kotlin comes with stdlib, 它包含了許多干凈利落的方法比如map和filter。這些方法非常普遍,而且不同編程語言都表現出相同的行為,包括Java 8 (streams)。不幸的是streams在安卓開發中還不能使用。
對我們的多選庫來說,我們需要對除了指定id的child之外的所有子view使用透明度動畫。下面的Kotlin代碼可以很好的完成:
if (view is ViewGroup) {
(0..view.childCount - 1)
.map { view.getChildAt(it) }
.filter { it.id != R.id.yal_ms_avatar }
.forEach { it.alpha = value }
}
要在Java上實現相同的事情可能會比這里的代碼多上一倍。
- 4.更好的語法
通常來說,Kotlin的語法比Java更簡潔易讀。
一個例子是when表達式。不同于Java的switch,Kotlin的when表達式返回一個值,所以你需要把它賦予一個變量或者從一個函數返回它。這個特性以及其本身可以讓代碼更短更易讀:
private fun getView(position: Int, pager: ViewPager): View = when (position) {
0 -> pageLeft
1 -> pageRight
else -> throw IllegalStateException()
}
如何使用MultiSelect
如果你想在項目中使用multiselect,這里是5個簡單的步驟。
1.首先,把下面的代碼添加到root build.gradle:
allprojects {
repositories {
...
maven { url "https://jitpack.io" }
}
}
然后添加下面的代碼到 module build.gradle:
dependencies {
compile 'com.github.yalantis:multi-selection:v0.1'
}
2.創建一個ViewHolder:
class ViewHolder extends RecyclerView.ViewHolder {
TextView name;
TextView comment;
ImageView avatar;
public ViewHolder(View view) {
super(view);
name = (TextView) view.findViewById(R.id.name);
comment = (TextView) view.findViewById(R.id.comment);
avatar = (ImageView) view.findViewById(R.id.yal_ms_avatar);
}
public static void bind(ViewHolder viewHolder, Contact contact) {
viewHolder.name.setText(contact.getName());
viewHolder.avatar.setImageURI(contact.getPhotoUri());
viewHolder.comment.setText(String.valueOf(contact.getTimesContacted()));
}
}
注意這個靜態bind方法。有了它你就可以在兩個adapter中使用相同的viewholder。
3.接下來,為未選中的列表和選中列表創建兩個adapter。第一個繼承BaseLeftAdapter,第二個繼承BaseRightAdapter:
public class LeftAdapter extends BaseLeftAdapter<Contact, ViewHolder>{
private final Callback callback;
public LeftAdapter(Callback callback) {
super(Contact.class);
this.callback = callback;
}
@Override
public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_view, parent, false);
return new ViewHolder(view);
}
@Override
public void onBindViewHolder(@NonNull final ViewHolder holder, int position) {
super.onBindViewHolder(holder, position);
ViewHolder.bind(holder, getItemAt(position));
holder.itemView.setOnClickListener(view -> {
// ...
callback.onClick(holder.getAdapterPosition());
// ...
});
}
}
選中列表的adapter與之類似:
public class RightAdapter extends BaseRightAdapter<Contact, ViewHolder> {
private final Callback callback;
public RightAdapter(Callback callback) {
this.callback = callback;
}
@Override
public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_view, parent, false);
return new ViewHolder(view);
}
@Override
public void onBindViewHolder(@NotNull final ViewHolder holder, int position) {
super.onBindViewHolder(holder, position);
ViewHolder.bind(holder, getItemAt(position));
holder.itemView.setOnClickListener(view -> {
// ...
callback.onClick(holder.getAdapterPosition());
// ...
});
}
}
Adapter繼承兩個不同基類的原因是未選中item是排好序的,而選中item按照被選擇的先后順序排列。
4.最后調用builder:
MultiSelectBuilder<Contact> builder = new MultiSelectBuilder<>(Contac
.withContext(this)
.mountOn((ViewGroup) findViewById(R.id.mount_point))
.withSidebarWidth(46 + 8 * 2); // ImageView width with paddings
你需要:
- 傳入context。
- 傳入你想把這個控件所要掛載到的view(通常為FrameLayout)。
- 指定sidebar的寬度(下圖所示)。
5.最后設置adapter:
LeftAdapter leftAdapter = new LeftAdapter(position -> mMultiSelect.select(position));
RightAdapter rightAdapter = new RightAdapter(position -> mMultiSelect.deselect(position));
leftAdapter.addAll(contacts);
builder.withLeftAdapter(leftAdapter)
.withRightAdapter(rightAdapter);
現在你要做的就是調用builder.build(),它將返回MultiSelect<T>實例。
你可以在我們的GitHub倉庫找到MultiSelect庫以及更多的項目。也可以到Dribbble上查看我們的概念設計:
GitHub
原文:Our Experiment Building a Multiselection Solution for Android in Kotlin