使用Kotlin实现一个酷炫的多选操作
dyy380neu
8年前
<p>“手机上的多选很难操作”,我们的设计师Vitaly Rubtsov如是说。大多数应用中的多选方案Telegram, Apple Music, Spotify等等通常都不是那么灵活,用起来也不舒服。</p> <p>比如,当你在Apple Music中创建自己的播放列表时,如果不切换屏幕或者无尽的滚动一遍被选中的歌曲,你都不清楚自己选择了哪些歌曲。</p> <p>如果我们想使用筛选功能事情就变得更糟糕了。应用了一个筛选条件之后,列表的结构可能会发生改变,选中的item也许根本就不会显示。Vitaly决定使用他自己的多选概念设计(最早发布在 <a href="/misc/goto?guid=4959723453270454385" rel="nofollow,noindex">Dribbble</a> )来解决这个问题。</p> <p>他的想法非常聪明:把屏幕分成两部分,就如Vitaly解释的那样,你总是能“看见和管理已经选择的项目,而不需要离开当前的视图”。而筛选只应用在主列表,不会影响已经选择的item列表。</p> <p>那时我明白了必须千方百计把Vitaly的多选概念设计实现出来;所以我几乎立即就开始了编写这个控件的工作。现在让我们来看看这个安卓的多选动画是如何诞生的。</p> <p><img src="https://simg.open-open.com/show/c308916d4f46d778e87919fb566c68df.gif"></p> <h2><strong>实现</strong></h2> <p>这个控件有一个带了两个RecyclerView的ViewPager,我们可以通过重写getPageWidth方法返回一个0到1之间的浮点数来让ViewPager的页面小于屏幕。</p> <p>一个具有两个页面的ViewPager,每个页面包含一个RecyclerView。未被选择的item在左边的列表。选中的item在右边的列表。比如,如果你点击了一个未被选择的item,将发生以下事情:</p> <ol> <li> <p>被点击的item从未被选中的item列表中移除并被添加到包含了两个列表的容器中。</p> </li> <li> <p>选中的item的位置是固定的。(未被选中的列表总是按照字母顺序排列。选中列表按照被选择的先后顺序排列)</p> </li> <li> <p>一个隐藏的item被添加到选中列表中。</p> </li> <li> <p>对被点击的item执行过渡动画。</p> </li> <li> <p>删除被点击的item并显示选中列表中隐藏的item。</p> </li> </ol> <p>这个过程中最技巧性的部分是把view从layout manager移除;否则layout manager 会尝试回收它,因为已经从RecyclerView删除了这个view,所以这会导致错误:</p> <pre> <code class="language-kotlin">sourceRecycler.layoutManager.removeViewAt(position)</code></pre> <h2><strong>技术栈</strong></h2> <p>我们选择Kotlin语言来做这个工作。和Java相比,Kotlin最主要的优点是其简明的语法和不会出现NullPointerException之类的崩溃。这里是我在实现这个库的过程中,Kotlin的这些特性给我带来了方便:</p> <ul> <li> <h3><strong>扩展函数</strong></h3> </li> </ul> <p>Kotlin的扩展函数功能使得我们可以为现有的类添加新的函数,而不用修改原来的类。</p> <p>就拿安卓的View来说。通常你需要把一个view从其父亲那里移除并挂载到新的view上。</p> <p>从view的父亲移除自己:</p> <pre> <code class="language-kotlin">fun View.removeFromParent() { val parent = this.parent if (parent is ViewGroup) { parent.removeView(this) } }</code></pre> <p>定义了上面的方法之后,你就可以在项目的任何地方这样调用它了:</p> <pre> <code class="language-kotlin"> view.removeFromParent()</code></pre> <p>你甚至可以直接写一个方法做完所有事情把一个view从当前父亲那里移除并挂载到新的view上:</p> <pre> <code class="language-kotlin">view.attachTo(newParent)</code></pre> <p>另一个好处是你可以添加setScaleXY方法。很少见到使用了setScaleX而不用setScaleY的情况,所以为什么不用一个方法设置两个Scale呢?让我们做一个这样的函数:</p> <pre> <code class="language-kotlin">fun View.setScaleXY(scale: Float) { scaleX = scale scaleY = scale }</code></pre> <p>你可以在library源码的 Extensions.kt文件中找到更多使用扩展函数的例子。</p> <ul> <li> <h3><strong>Null safety</strong></h3> </li> </ul> <p>Kotlin的null safety特性是一个规则改变者 ‘?.’操作符和 ‘.’ 一样的意思只是如果对象是null而被调用的话不会抛出NullPointerException,而是返回null:</p> <pre> <code class="language-kotlin">var targetView: View? = targetRecycler.findViewHolderForAdapterPosition(prev)?.itemView</code></pre> <p>上面的代码中,即使findViewHolderForAdapterPosition返回null也不会崩溃。</p> <ul> <li> <h3><strong>Collections</strong></h3> </li> </ul> <p>Kotlin comes with stdlib, 它包含了许多干净利落的方法比如map和filter。这些方法非常普遍,而且不同编程语言都表现出相同的行为,包括Java 8 (streams)。不幸的是streams在安卓开发中还不能使用。</p> <p>对我们的多选库来说,我们需要对除了指定id的child之外的所有子view使用透明度动画。下面的Kotlin代码可以很好的完成:</p> <pre> <code class="language-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 } }</code></pre> <p>要在Java上实现相同的事情可能会比这里的代码多上一倍。</p> <ul> <li> <h3><strong>更好的语法</strong></h3> </li> </ul> <p>通常来说,Kotlin的语法比Java更简洁易读。</p> <p>一个例子是when表达式。不同于Java的switch,Kotlin的when表达式返回一个值,所以你需要把它赋予一个变量或者从一个函数返回它。这个特性以及其本身可以让代码更短更易读:</p> <pre> <code class="language-kotlin">private fun getView(position: Int, pager: ViewPager): View = when (position) { 0 -> pageLeft 1 -> pageRight else -> throw IllegalStateException() }</code></pre> <h2><strong>如何使用MultiSelect</strong></h2> <p>如果你想在项目中使用multiselect,这里是5个简单的步骤。</p> <p>1. 首先,把下面的代码添加到root build.gradle:</p> <pre> <code class="language-kotlin">allprojects { repositories { ... maven { url "https://jitpack.io" } } }</code></pre> <p>然后添加下面的代码到 module build.gradle:</p> <pre> <code class="language-kotlin"> dependencies { compile 'com.github.yalantis:multi-selection:v0.1' }</code></pre> <p>2. 创建一个ViewHolder:</p> <pre> <code class="language-kotlin">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())); } }</code></pre> <p>注意这个静态bind方法。有了它你就可以在两个adapter中使用相同的viewholder。</p> <p>3. 接下来,为未选中的列表和选中列表创建两个adapter。第一个继承BaseLeftAdapter,第二个继承BaseRightAdapter:</p> <pre> <code class="language-kotlin">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()); // ... }); } }</code></pre> <p>选中列表的adapter与之类似:</p> <pre> <code class="language-kotlin">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()); // ... }); } }</code></pre> <p>Adapter继承两个不同基类的原因是未选中item是排好序的,而选中item按照被选择的先后顺序排列。</p> <p>4.最后调用builder:</p> <pre> <code class="language-kotlin">MultiSelectBuilder<Contact> builder = new MultiSelectBuilder<>(Contac .withContext(this) .mountOn((ViewGroup) findViewById(R.id.mount_point)) .withSidebarWidth(46 + 8 * 2); // ImageView width with paddings</code></pre> <p>你需要:</p> <ul> <li> <p>传入context。</p> </li> <li> <p>传入你想把这个控件所要挂载到的view(通常为FrameLayout)。</p> </li> <li> <p>指定sidebar的宽度(下图所示)。</p> </li> </ul> <p style="text-align:center"><img src="https://simg.open-open.com/show/d395bc4ddbbe093b7ffb93751c2aced4.jpg"></p> <p>5. 最后设置adapter:</p> <pre> <code class="language-kotlin">LeftAdapter leftAdapter = new LeftAdapter(position -> mMultiSelect.select(position)); RightAdapter rightAdapter = new RightAdapter(position -> mMultiSelect.deselect(position)); leftAdapter.addAll(contacts); builder.withLeftAdapter(leftAdapter) .withRightAdapter(rightAdapter);</code></pre> <p>现在你要做的就是调用builder.build(),它将返回MultiSelect<T>实例。</p> <p>你可以在我们的GitHub仓库找到MultiSelect库以及更多的项目。也可以到Dribbble上查看我们的概念设计:</p> <p> </p> <p> </p> <p>来自:http://www.jcodecraeer.com/a/anzhuokaifa/androidkaifa/2016/1102/6734.html</p> <p> </p>