IM中按名称拼音字母分组排序

在IM项目(Android项目)中,例如群成员列表,通讯录(仿微信)等等。往往会按名称首字母分组并排序。从而方便用户检索。

需求:

先上一张UI效果图:

效果t

分析需求

  1. 每个item需要按首字母分组,群主和管理员单独一组。A~Z以外的字符放入‘#’这组。
  2. 每组内按文字拼音排序。
  3. 每组之间有分隔标题。
  4. 右侧 SideBar (自定义View)快速检索。

:SideBar自定义View并非本文重点。当作有这个View就是了,文末会给代码,自己去实现更好哈😊。

方案设计

按字母分组:

针对需求1,2。写一个通用的类去做这个时。(尽量与具体业务解耦,也方便日后总结。)

object LatterSetUtil {
    // "★" 代表特殊分类。
    private val LETTERS = arrayOf("★", "A", "B", "C", "D", "E", "F", "G", "H", "I",
            "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V",
            "W", "X", "Y", "Z", "#")
    /**
     * 容器。
     */
    class Container<T : ILetter> {
        internal val map: HashMap<String, ArrayList<T>> = HashMap()
        init {
            // 建立字母分组map。
            for (s in LETTERS) {
                map[s] = ArrayList()
            }
        }
        /**
         * 排序后的列表。
         */
        fun getSortList(sort: (ArrayList<T>) -> Unit, addLetter: Boolean = true): List<Any> {
            val resultList = ArrayList<Any>()
            // 将分组结果排成列表。
            for (s in LETTERS) {
                val list = map[s]
                // 集合非空才能加入。
                if (list.isNullOrEmpty()) continue
                if (addLetter) {
                    resultList.add(Letter(s, list.size))
                }
                sort(list)
                resultList.addAll(list)
            }
            return resultList
        }
    }
    class Letter(val letter: String, val size: Int)
    interface ILetter {
        /**
         * 获取首字母。
         */
        fun getFirstLetter(): String = "#"
    }
    /**
     * 按字母分组。
     *
     * @param dataList 数据源。
     */
    fun <T : ILetter> getContainer(dataList: List<T>): Container<T> {
        val c = Container<T>()
        // 默认放入"#"集合。
        val defList = c.map["#"] ?: return c
        // 将原数据分组。
        for (ifl in dataList) {
            // 获取首字母。
            val s = ifl.getFirstLetter()
            val list = c.map[s] ?: defList
            // 加入对应字母的小组。
            list.add(ifl)
        }
        return c
    }
}

使用:

  1. 数据源需要实现LatterSetUtil.ILetter接口。
  2. 放入数据源(list)返回一个容器对象。里面是已经按字母分好组的集合。
  3. 调用getSortList方法,返回一个list。外部指定组内排序规则,每组之前会插一个Letter记录首字母和这组元素的数量。

获取首字母

根据中文获取首字母,原先,自己写了个根据汉字编码规律,按字符区间去判断首字母的方法。能覆盖大多场景,但是很快就被测试找出了反例😓。于是采用现有的“汉语拼音”库:pinyin4j。

    implementation 'com.belerweb:pinyin4j:2.5.1'

代码:

public class FirstLetterUtil {
    // 根据一个包含汉字的字符串返回一个汉字拼音首字母的字符串 最重要的一个方法.
    @NonNull
    public static String first(@Nullable String str) {
        if (str == null || str.equals("")) {
            return "#";
        }
        char ch = str.charAt(0);
        if (ch >= 'a' && ch <= 'z') {
            return (char) (ch - 'a' + 'A') + "";
        }
        if (ch >= 'A' && ch <= 'Z') {
            return ch + "";
        }
        try {
            HanyuPinyinOutputFormat defaultFormat = new HanyuPinyinOutputFormat();
            // 设置大小写格式
            defaultFormat.setCaseType(HanyuPinyinCaseType.UPPERCASE);
            // 设置声调格式:
            defaultFormat.setToneType(HanyuPinyinToneType.WITHOUT_TONE);
            if (Character.toString(ch).matches("[\\u4E00-\\u9FA5]+")) {
                String[] array = PinyinHelper.toHanyuPinyinStringArray(ch, defaultFormat);
                if (array != null) {
                    return array[0].charAt(0) + "";
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
        return "#";
    }
}

PS:HanyuPinyin:汉语拼音。。。。汗😓。。

配一张图

使用:

让MemberVhModel实现LatterSetUtil.ILetter接口,getFirstLetter()实现为返回this.latter。latter属性在设置名称时赋值(利用FirstLetterUtil)。

:不直接在getFirstLetter()方法返回FirstLetterUtil.first(name)。是因为FirstLetterUtil的这个方法效率并不是很高,而getFirstLetter()调用可能较为频繁。其次,MemberVhModel尽量写数据,业务逻辑最好解耦。

组合列表

将上述内容组合起来。

/**
     * 列表变化。
     */
    private fun sortMemberAndLetterList(dataList: List<MemberVhModel>, memberSet: MemberSetModel) {
        memberSet.clearMembers()
        val container = LatterSetUtil.getContainer(dataList)
        val lsList = container.getSortList({ sortMemberList(it) })
        for (ls in lsList) {
            if (ls is LatterSetUtil.Letter) {
                val model = MemberTitleVhModel(title = ls.letter, letter = ls.letter, size = ls.size)
                if (ls.letter == ADMIN_LETTER) {// 管理员。
                    model.title = String.format(getString(R.string.im_group_admin_count), ls.size)
                }
                memberSet.letterList.add(ls.letter)
                memberSet.itemList.add(model)
            } else if (ls is MemberVhModel) {
                memberSet.itemList.add(ls)
                memberSet.userList.add(ls)
            }
        }
    }

组内排序:

private val cmp = Collator.getInstance(Locale.CHINA)!!
    /**
     * 排序。
     */
    fun sortMemberList(list: ArrayList<MemberVhModel>) {
        list.sortWith(Comparator { l, r ->cmp.compare(l.name, r.name)})
    }

UI方案

xml布局:把这个布局include到具体的大页面中。

<androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@color/color_EEEEEE">
        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/rv_member"
            binding_rv_data="@{item.syncList}"
            binding_rv_noAnim="@{true}"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:background="@color/color_EEEEEE"
            android:orientation="vertical"
            android:scrollbars="none"
            app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager">
        </androidx.recyclerview.widget.RecyclerView>
        <--这是吸顶的title。-->
        <TextView 
            android:id="@+id/tv_title"
            android:layout_width="match_parent"
            android:layout_height="@dimen/pt_36"
            android:background="@color/white"
            android:gravity="center_vertical"
            android:paddingStart="@dimen/pt_15"
            android:paddingEnd="@dimen/pt_15"
            android:textColor="@color/color_3CC55D"
            android:textSize="@dimen/pt_17"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            tools:text="A" />
        <自定义的.SideBar
            android:id="@+id/sb_letter"
            android:layout_width="@dimen/pt_40"
            android:layout_height="match_parent"
            android:layout_marginTop="@dimen/pt_20"
            android:layout_marginBottom="@dimen/pt_30"
            android:focusable="true"
            android:paddingStart="@dimen/pt_20"
            android:paddingEnd="10dp"
            android:textColorHighlight="@color/color_3CC55D"
            android:textSize="@dimen/pt_12_5"
            app:layout_constraintEnd_toEndOf="parent" />
        <--这是按住SideBar展示的字母。-->
        <TextView 
            android:id="@+id/tv_letter"
            android:layout_width="@dimen/pt_54"
            android:layout_height="@dimen/pt_45"
            android:layout_marginEnd="@dimen/pt_40"
            android:background="@drawable/im_bg_side_bar_txt"
            android:gravity="center"
            android:includeFontPadding="false"
            android:paddingStart="@dimen/pt_1"
            android:paddingEnd="@dimen/pt_10"
            android:textColor="@color/color_3CC55D"
            android:textSize="@dimen/pt_20"
            android:visibility="gone"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintTop_toTopOf="@id/sb_letter"
            tools:text="★"
            tools:visibility="visible" />
    </androidx.constraintlayout.widget.ConstraintLayout>

RecyclerView与SideBar有联动效果。并且与具体数据业务无关。所以把这部分代码解耦出来。不单成员列表一个页面用。添加,删除群成员,AT成员页面都有一样的逻辑。要学会抽离公共逻辑👌。

object MemberListUI {
    // 数据记录。
    private class Data(var lastPosition: Int = -1)
    fun init(binding: ImCommonMemberListBinding, rvAdapter: RecyclerView.Adapter<*>) {
        val data = Data()
        // 这是RecyclerView。
        binding.rvMember.run {
            adapter = rvAdapter
            addOnScrollListener(object : RecyclerView.OnScrollListener() {
                override fun onScrolled(v: RecyclerView, dx: Int, dy: Int) {
                    super.onScrolled(v, dx, dy)
                    val item = binding.item ?: return
                    val headerCount = MemberUtil.getHeaderCount(item)
                    // v.getChildAt(0)不会越界异常,超出索引会返回null。
                    val position = v.getChildAt(0)?.let { v.getChildLayoutPosition(it) } ?: 0
                    // 吸顶效果。
                    binding.tvTitle.setVisible(position > headerCount)
                    // position发生变化时。
                    if (data.lastPosition != position) {
                        binding.tvTitle.text = MemberUtil.getTitleByIndex(item, position - headerCount)
                        data.lastPosition = position
                    }
                }
            })
        }
        // 这是SlideBar。
        binding.sbLetter.run {
            setTextView(binding.tvLetter)
            setOnTouchingLetterChangedListener { letter ->
                // 联动成员列表。
                val item = binding.item ?: return@setOnTouchingLetterChangedListener
                val index = MemberUtil.getIndexByLetter(item, letter)
                if (index < 0) return@setOnTouchingLetterChangedListener
                // 列表前可能有header。
                val headerCount = MemberUtil.getHeaderCount(item)
                val position = index + headerCount
                if (position in 0 until rvAdapter.itemCount) {
                    val layoutManager = binding.rvMember.layoutManager
                    if (layoutManager is LinearLayoutManager) {
                        layoutManager.scrollToPositionWithOffset(position, 0)
                    }
                    if (data.lastPosition != position) {
                        binding.tvTitle.text = MemberUtil.getTitleByIndex(item, position - headerCount)
                        data.lastPosition = position
                    }
                }
            }
        }
    }
}

使用:

只需要一句话。写在View的初始化处。(vList是include的布局ID转过来的binding。)

MemberListUI.init(binding.vList, memberAdapter)

总结

这篇的借着群成员列表的业务,主要想讲述一下几点。

要点:

  1. 数据结构相关,尽量从业务中抽离。达到可复用效果。
  2. 数据类尽量不写具体逻辑。除了实现接口,尽可能简单。复杂逻辑外面去做。
  3. 公共UI试着抽离业务。

体会:

  1. 把代码实现有条理一点,总结起来愉快一些😊。

附件

SideBar.java

推荐阅读更多精彩内容