前言:

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

需求:

先上一张UI效果图:

效果t

分析需求

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

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

方案设计

按字母分组:

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
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。

1
implementation 'com.belerweb:pinyin4j:2.5.1'

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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尽量写数据,业务逻辑最好解耦。

组合列表

将上述内容组合起来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* 列表变化。
*/
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)
}
}
}

组内排序:

1
2
3
4
5
6
7
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到具体的大页面中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
<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成员页面都有一样的逻辑。要学会抽离公共逻辑👌。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
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。)

1
MemberListUI.init(binding.vList, memberAdapter)

总结

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

要点:

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

体会:

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

附件

SideBar.java