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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,590评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 86,808评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,151评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,779评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,773评论 5 367
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,656评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,022评论 3 398
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,678评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,038评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,659评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,756评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,411评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,005评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,973评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,053评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,495评论 2 343