之前我在『ArkUI AlphabetIndexer 真香』一文中提到,Android 平台也自带了一个 AlphabetIndexer,不少 Android 开发者都表示没见过这玩意儿。

Android 中的字母索引器的 UI 其实应该叫 FastScroller,即「快速滑动块」,而 AlphabetIndexer 这个类也确实存在,只不过它是作为辅助类来处理逻辑,FastScroller 才是处理滑块的显示。 因为使用起来不方便又或者是样式不符合国人使用习惯等原因,在国内的 App 中都不常见,我们可以先看看它的效果:

FastScroller

今天就来聊聊怎么实现,虽然日常基本不会使用,就当涨涨见识。

首先来看看布局,列表的子项就一个 TextView,没什么好说的。

<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/tv_contact"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:padding="16dp"
    android:textSize="16sp"
    android:textStyle="bold" />

页面则使用一个 ListView 来做展示。

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <com.google.android.material.appbar.MaterialToolbar
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="@android:color/holo_purple"
        app:title="Contacts" />

    <ListView
        android:id="@+id/list_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:fastScrollEnabled="true"
        tools:listitem="@layout/item_contact" />
</LinearLayout>

注意这里要配置 android:fastScrollEnabled 属性。

不使用 RecyclerView 的原因是它还需要配置其他属性,相对来说更加复杂,后续后机会再单独介绍。

接下来编写适配器:

class ContactAdapter(
    context: Context,
    cursor: Cursor,
    data: List<Map<String, String>>,
    @LayoutRes resource: Int,
    from: Array<String>,
    @IdRes to: IntArray
) : SimpleAdapter(context, data, resource, from, to), SectionIndexer {

    private val indexer = AlphabetIndexer(cursor, 0, "ABCDEFGHIJKLMNOPQRSTUVWXYZ")

    override fun getSections(): Array<Any> {
        return indexer.sections
    }

    override fun getPositionForSection(sectionIndex: Int): Int {
        return indexer.getPositionForSection(sectionIndex)
    }

    override fun getSectionForPosition(position: Int): Int {
        return indexer.getSectionForPosition(position)
    }
}

前面的布局中可以看到样式十分简单,所以这里直接继承 SimpleAdapter,同时实现 SectionIndexer,这里十分关键。

构建 AlphabetIndexer,需要用到 Cursor,我们从上一层传入,并将 26 个字母作为索引。

AlphabetIndexer 本身也是 SectionIndexer 的子类,所以在我们实现的 SectionIndexer 的接口中调用 AlphabetIndexer 的对应方法返回。

提到 Cursor,我们最先想到可以直接查询本地通讯录的数据,它会给我们返回一个 Cursor 对象,就不用我们手动封装数据源了:

class MainActivity : AppCompatActivity() {

    private val binding by lazy { ActivityMainBinding.inflate(layoutInflater) }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(binding.root)
        val cursor = contentResolver.query(
            ContactsContract.Contacts.CONTENT_URI,
            arrayOf(ContactsContract.Contacts.DISPLAY_NAME_PRIMARY),
            null,
            null,
            ContactsContract.Contacts.DISPLAY_NAME_PRIMARY
        )
        startManagingCursor(cursor)
        cursor?.let {
            val list = getData(it)
            val adapter = ContactAdapter(
                this,
                it,
                list,
                R.layout.item_contact,
                arrayOf(ContactsContract.Contacts.DISPLAY_NAME_PRIMARY),
                intArrayOf(R.id.tv_contact)
            )
            binding.listView.adapter = adapter
        }
    }

    private fun getData(cursor: Cursor): List<Map<String, String>> {
        val list = mutableListOf<Map<String, String>>()
        if (cursor.moveToFirst()) {
            do {
                val column = cursor.getColumnIndex(ContactsContract.Contacts.DISPLAY_NAME_PRIMARY)
                val name = cursor.getString(column)
                list.add(mapOf(ContactsContract.Contacts.DISPLAY_NAME_PRIMARY to name))
            } while (cursor.moveToNext())
        }
        return list
    }
}

Cursor 数据取出 ContactsContract.Contacts.DISPLAY_NAME_PRIMARY 这一列用于组装子项,再将 Cursor 传入刚刚的 ContactAdapter 中构建 AlphabetIndexer

获取联系人还需要动态申请权限,这部分代码没有给出,自行处理即可。

有些朋友敲完之后可能会发现侧边的索引器出不来,其实不是代码的问题,只是数据量不够导致的,你可以配置 android:fastScrollAlwaysVisible 属性让它保持显示:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout ...>
    ...
    <ListView ...
        android:fastScrollAlwaysVisible="true"
        android:fastScrollEnabled="true" />
</LinearLayout>

如果不配置该属性,默认情况下只有当内容大于 4 页时,ListView 才会显示索引器,我们可以在源码中得出这个结论。

ListView 的父类 AbsListView 的构造方法中有 android:fastScrollEnabled 这个属性的配置:

public abstract class AbsListView extends AdapterView<ListAdapter> implements TextWatcher,
        ViewTreeObserver.OnGlobalLayoutListener, Filter.FilterListener,
        ViewTreeObserver.OnTouchModeChangeListener,
        RemoteViewsAdapter.RemoteAdapterConnectionCallback {
    
    @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.P, trackingBug = 123768941)
    private FastScroller mFastScroll;
    
    public AbsListView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
        ...
        final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.AbsListView, defStyleAttr, defStyleRes);
        setFastScrollEnabled(a.getBoolean(R.styleable.AbsListView_fastScrollEnabled, false));
        setFastScrollStyle(a.getResourceId(R.styleable.AbsListView_fastScrollStyle, 0));
        setFastScrollAlwaysVisible(a.getBoolean(R.styleable.AbsListView_fastScrollAlwaysVisible, false));
        ...
    }
    
    public void setFastScrollEnabled(final boolean enabled) {
        if (mFastScrollEnabled != enabled) {
            mFastScrollEnabled = enabled;
            if (isOwnerThread()) {
                setFastScrollerEnabledUiThread(enabled);
            } else {
                post(new Runnable() {
                    @Override
                    public void run() {
                        setFastScrollerEnabledUiThread(enabled);
                    }
                });
            }
        }
    }

    private void setFastScrollerEnabledUiThread(boolean enabled) {
        if (mFastScroll != null) {
            mFastScroll.setEnabled(enabled);
        } else if (enabled) {
            mFastScroll = new FastScroller(this, mFastScrollStyle);
            mFastScroll.setEnabled(true);
        }
        resolvePadding();
        if (mFastScroll != null) {
            mFastScroll.updateLayout();
        }
    }
}

可以看到 ListView 的快速滑动块是通过 FastScroller 类实现的,接下来看看 FastScroller 的创建流程:

/**
 * Helper class for AbsListView to draw and control the Fast Scroll thumb
 */
class FastScroller {
    /** Minimum number of pages to justify showing a fast scroll thumb. */
    private static final int MIN_PAGES = 4;

    /**
     * @return Whether the fast scroll thumb is enabled.
     */
    public boolean isEnabled() {
        return mEnabled && (mLongList || mAlwaysShow);
    }
    
    /**
     * Called when one of the variables affecting enabled state changes.
     *
     * @param peekIfEnabled whether the thumb should peek, if enabled
     */
    private void onStateDependencyChanged(boolean peekIfEnabled) {
        if (isEnabled()) {
            if (isAlwaysShowEnabled()) {
                setState(STATE_VISIBLE);
            } else if (mState == STATE_VISIBLE) {
                postAutoHide();
            } else if (peekIfEnabled) {
                setState(STATE_VISIBLE);
                postAutoHide();
            }
        } else {
            stop();
        }
        mList.resolvePadding();
    }

    private void updateLongList(int childCount, int itemCount) {
        final boolean longList = childCount > 0 && itemCount / childCount >= MIN_PAGES;
        if (mLongList != longList) {
            mLongList = longList;
            onStateDependencyChanged(false);
        }
    }
    ...
}

不难理解,当开启 android:fastScrollEnabled 配置时,有两种条件可以显示快速滑动块,一是长列表,二是 android:fastScrollAlwaysVisible 也为 true,长列表的判断条件是数据量大于等于 MIN_PAGES,即 4 页。

以上就是 Android 字母索引的简单实现,关于自定义等其他用法就不展开介绍了,毕竟使用率不高,以后有机会再聊。