美文网首页
IM中按名称拼音字母分组排序

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

作者: wzmyyj | 来源:发表于2020-05-12 14:30 被阅读0次

    在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

    相关文章

      网友评论

          本文标题:IM中按名称拼音字母分组排序

          本文链接:https://www.haomeiwen.com/subject/jqqsnhtx.html