一次使用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阅读 151,511评论 1 330
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 64,495评论 1 273
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 101,595评论 0 225
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 42,558评论 0 190
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 50,715评论 3 270
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 39,672评论 1 192
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,112评论 2 291
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 29,837评论 0 181
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 33,417评论 0 228
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 29,928评论 2 232
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,316评论 1 242
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 27,773评论 2 234
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,253评论 3 220
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 25,827评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,440评论 0 180
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 34,523评论 2 249
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 34,583评论 2 249

推荐阅读更多精彩内容