一次使用Kotlin實現酷炫多選操作的嘗試

“手機上的多選很難操作”,我們的設計師Vitaly Rubtsov如是說。大多數應用中的多選方案 -Telegram, Apple Music, Spotify等等- 通常都不是那么靈活,用起來也不舒服。

比如,當你在Apple Music中創建自己的播放列表時,如果不切換屏幕或者無盡的滾動一遍被選中的歌曲,你都不清楚自己選擇了哪些歌曲。

如果我們想使用篩選功能事情就變得更糟糕了。應用了一個篩選條件之后,列表的結構可能會發生改變,選中的item也許根本就不會顯示。Vitaly決定使用他自己的多選概念設計(最早發布在Dribbble)來解決這個問題。

他的想法非常聰明:把屏幕分成兩部分,就如Vitaly解釋的那樣,你總是能“看見和管理已經選擇的項目,而不需要離開當前的視圖”。而篩選只應用在主列表,不會影響已經選擇的item列表。

那時我明白了必須千方百計把Vitaly的多選概念設計實現出來;所以我幾乎立即就開始了編寫這個控件的工作。現在讓我們來看看這個安卓的多選動畫是如何誕生的。

1478063387383413.gif

實現

這個控件有一個帶了兩個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的寬度(下圖所示)。
how-we-build-a-multiselection-component-for-android-application

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

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,797評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,179評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,628評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,642評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,444評論 6 405
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,948評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,040評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,185評論 0 287
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,717評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,602評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,794評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,316評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,045評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,418評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,671評論 1 281
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,414評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,750評論 2 370

推薦閱讀更多精彩內容