天天看点

Android设置之Preference源码实现

本篇分析的库是基于:androidx.preference.preference:[email protected]

 先来看张微信中的页面,这个页面实现其来比较简单,实现的方式也有很多,但按可扩张性和简单程度来说,个人认为还是要数Preference了,基本就是xml中配置了。

Android设置之Preference源码实现

android中有提供给我们专门用作设置处理的库Preference(支持的控件可直接在该库下查看),对于怎么使用,android studio有提供模板Settings Activity,接下来就看下它的实现。

先来简单看下Preference定义的xml:

<PreferenceScreen xmlns:app="http://schemas.android.com/apk/res-auto">
    <PreferenceCategory app:title="@string/sync_header">
        <SwitchPreferenceCompat
            app:key="sync"
            app:title="@string/sync_title" />
        <SwitchPreferenceCompat
            app:dependency="sync"
            app:key="attachment"
            app:switchTextOn="kai"
            app:switchTextOff="guan"
            app:summaryOff="@string/attachment_summary_off"
            app:summaryOn="@string/attachment_summary_on"
            app:title="@string/attachment_title" />
    </PreferenceCategory>
</PreferenceScreen>
           

在来看下在Fragment中的使用:

public static class SettingsFragment extends PreferenceFragmentCompat {
    @Override
    public void onCreatePreferences(Bundle savedInstanceState, String rootKey) {
        setPreferencesFromResource(R.xml.root_preferences, rootKey);
    }
}
           

这里就是调用了setPreferencesFromResource()把xml设置进去,接着就是使用这个Fragment就可以了。

咋一看,这里并没有view啊,那是设置页面是怎么显示的呢,带着这个疑问,一起来看下PreferenceFragmentCompat的源码,Fragment创建view是在onCreateView这个方法中:

private int mLayoutResId = R.layout.preference_list_fragment;
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
        @Nullable Bundle savedInstanceState) {
​
    TypedArray a = getContext().obtainStyledAttributes(null,
            R.styleable.PreferenceFragmentCompat,
            R.attr.preferenceFragmentCompatStyle,
            0);
​
    mLayoutResId = a.getResourceId(R.styleable.PreferenceFragmentCompat_android_layout,
            mLayoutResId);
​
    ... ...
​
    a.recycle();
​
    final LayoutInflater themedInflater = inflater.cloneInContext(getContext());
​
    final View view = themedInflater.inflate(mLayoutResId, container, false);
​
    final View rawListContainer = view.findViewById(AndroidResources.ANDROID_R_LIST_CONTAINER);
    if (!(rawListContainer instanceof ViewGroup)) {
        throw new IllegalStateException("Content has view with id attribute "
                + "'android.R.id.list_container' that is not a ViewGroup class");
    }
​
    final ViewGroup listContainer = (ViewGroup) rawListContainer;
​
    final RecyclerView listView = onCreateRecyclerView(themedInflater, listContainer,
            savedInstanceState);
    if (listView == null) {
        throw new RuntimeException("Could not create RecyclerView");
    }
​
    mList = listView;
​
    listView.addItemDecoration(mDividerDecoration);
    setDivider(divider);
    if (dividerHeight != -1) {
        setDividerHeight(dividerHeight);
    }
    mDividerDecoration.setAllowDividerAfterLastItem(allowDividerAfterLastItem);
​
    // If mList isn't present in the view hierarchy, add it. mList is automatically inflated
    // on an Auto device so don't need to add it.
    if (mList.getParent() == null) {
        listContainer.addView(mList);
    }
    mHandler.post(mRequestFocus);
​
    return view;
}
           

这里的style/declare-styleable/attr以及后面用到的都是定义在该库的res/values/values.xml中。可以配合着来看,在values.xml中并没有定义layout,所以使用的是默认的R.layout.preference_list_fragment,这是系统定义的资源文件,

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    tools:ignore="NewApi"
    android:orientation="vertical"
    android:layout_height="match_parent"
    android:layout_width="match_parent" >
​
    <FrameLayout
        android:id="@android:id/list_container"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1" />
​
    <TextView android:id="@android:id/empty"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:padding="8dp"
        android:gravity="center"
        android:visibility="gone" />
​
</LinearLayout>
           

配合着对应的xml看就简单多了,这里用到的就是一个id为list_container的FrameLayout,接着就是创建一个RecyclerView加入到这个FrameLayout中去,这里再来看下RecyclerView的创建:

public RecyclerView onCreateRecyclerView(LayoutInflater inflater, ViewGroup parent,
        Bundle savedInstanceState) {
    // If device detected is Auto, use Auto's custom layout that contains a custom ViewGroup
    // wrapping a RecyclerView
    if (getContext().getPackageManager().hasSystemFeature(PackageManager
            .FEATURE_AUTOMOTIVE)) {
        RecyclerView recyclerView = parent.findViewById(R.id.recycler_view);
        if (recyclerView != null) {
            return recyclerView;
        }
    }
    // 通常是执行这里
    RecyclerView recyclerView = (RecyclerView) inflater
            .inflate(R.layout.preference_recyclerview, parent, false);
​
    recyclerView.setLayoutManager(onCreateLayoutManager());
    recyclerView.setAccessibilityDelegateCompat(
            new PreferenceRecyclerViewAccessibilityDelegate(recyclerView));
​
    return recyclerView;
}
           

对应的xml是:

<androidx.recyclerview.widget.RecyclerView
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/recycler_view"
    style="?attr/preferenceFragmentListStyle"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:paddingTop="0dp"
    android:paddingBottom="0dp"
    android:clipToPadding="false"/>
           

都比较简单,总结就是创建了一个RecyclerView加入到了FrameLayout中,有了RecyclerView,显示自然需要用到adapter了,设置adapter如下:

void bindPreferences() {
    final PreferenceScreen preferenceScreen = getPreferenceScreen();
    if (preferenceScreen != null) {
        getListView().setAdapter(onCreateAdapter(preferenceScreen));
        preferenceScreen.onAttached();
    }
    onBindPreferences();
}
           

创建adapter传入的是PreferenceScreen对象,这个对象是怎么来的呢?回到一开始的setPreferencesFromResource()方法:

public void setPreferencesFromResource(@XmlRes int preferencesResId, @Nullable String key) {
    requirePreferenceManager();
​
    final PreferenceScreen xmlRoot = mPreferenceManager.inflateFromResource(getContext(),
            preferencesResId, null);
​
    final Preference root;
    // key默认为null
    if (key != null) {
        root = xmlRoot.findPreference(key);
        if (!(root instanceof PreferenceScreen)) {
            throw new IllegalArgumentException("Preference object with key " + key
                    + " is not a PreferenceScreen");
        }
    } else {
        root = xmlRoot;
    }
​
    setPreferenceScreen((PreferenceScreen) root);
}
           

创建adapter传入的PreferenceScreen就是在这里创建的了,preferencesResId就是我们一开始传入的xml文件,这里创建PreferenceScreen和创建view很像,这里就不跟进去看了,无非就是拿到xml中配置的信息通过反射创建对象,接着回到adapter创建:

protected RecyclerView.Adapter onCreateAdapter(PreferenceScreen preferenceScreen) {
    return new PreferenceGroupAdapter(preferenceScreen);
}
           

PreferenceGroupAdapter实现了RecyclerView.Adapter,那就来看下它的onCreateViewHolder方法:

public PreferenceViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
    ... ...
​
    final View view = inflater.inflate(descriptor.mLayoutResId, parent, false);
    if (view.getBackground() == null) {
        ViewCompat.setBackground(view, background);
    }
​
    final ViewGroup widgetFrame = view.findViewById(android.R.id.widget_frame);
    if (widgetFrame != null) {
        if (descriptor.mWidgetLayoutResId != 0) {
            inflater.inflate(descriptor.mWidgetLayoutResId, widgetFrame);
        } else {
            widgetFrame.setVisibility(View.GONE);
        }
    }
​
    return new PreferenceViewHolder(view);
}
           

这里的descriptor对象封装的是preference相关的布局文件,到这里会有一个疑惑,我们在xml中明明没有配置layout,那这里创建view的layout是从哪里来的呢?那就得先来看看Preference这个类的构造函数了:

public Preference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
    final TypedArray a = context.obtainStyledAttributes(
            attrs, R.styleable.Preference, defStyleAttr, defStyleRes);
    ... ...
​
    mLayoutResId = TypedArrayUtils.getResourceId(a, R.styleable.Preference_layout,
            R.styleable.Preference_android_layout, R.layout.preference);
​
    mWidgetLayoutResId = TypedArrayUtils.getResourceId(a, R.styleable.Preference_widgetLayout,
            R.styleable.Preference_android_widgetLayout, 0);
​
    ... ...
​
    a.recycle();
}
           

这里就是获取layoutId,如果我们的xml中没有配置,那么就使用默认的,mWidgetLayoutResId是设置项里面的控制按钮,那这里的id是如何根据不同的preference来确定不同的id呢?这里以SwitchPreferenceCompat为例:

public SwitchPreferenceCompat(Context context, AttributeSet attrs) {
    this(context, attrs, R.attr.switchPreferenceCompatStyle);
}
           

这里指定了主题中使用的值为switchPreferenceCompatStyle,再来看下主题定义:

<style name="PreferenceThemeOverlay">
    ... ...
    <item name="switchPreferenceCompatStyle">@style/Preference.SwitchPreferenceCompat.Material</item>
    ... ...
</style>
<style name="Preference.SwitchPreferenceCompat.Material">
    <item name="android:layout">@layout/preference_material</item>
    <item name="allowDividerAbove">false</item>
    <item name="allowDividerBelow">true</item>
    <item name="iconSpaceReserved">@bool/config_materialPreferenceIconSpaceReserved</item>
</style>
<style name="Preference.SwitchPreferenceCompat">
    <item name="android:widgetLayout">@layout/preference_widget_switch_compat</item>
    <item name="android:switchTextOn">@string/v7_preference_on</item>
    <item name="android:switchTextOff">@string/v7_preference_off</item>
</style>
<style name="Preference">
    <item name="android:layout">@layout/preference</item>
</style>
           

可以看到,SwitchPreferenceCompat默认使用的是preference_widget_switch_compat.xml,这里就只是定义了一个SwitchCompat控制,这里来看下主布局mLayoutResId(preference.xml):

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:minHeight="?android:attr/listPreferredItemHeight"
    android:gravity="center_vertical"
    android:paddingEnd="?android:attr/scrollbarSize"
    android:paddingRight="?android:attr/scrollbarSize"
    android:background="?android:attr/selectableItemBackground">
​
    <FrameLayout
        android:id="@+id/icon_frame"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content">
        <androidx.preference.internal.PreferenceImageView
            android:id="@android:id/icon"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:maxWidth="48dp"
            app:maxHeight="48dp" />
    </FrameLayout>
​
    <RelativeLayout
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginStart="15dip"
        android:layout_marginLeft="15dip"
        android:layout_marginEnd="6dip"
        android:layout_marginRight="6dip"
        android:layout_marginTop="6dip"
        android:layout_marginBottom="6dip"
        android:layout_weight="1">
​
        <TextView android:id="@android:id/title"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:singleLine="true"
            android:textAppearance="?android:attr/textAppearanceLarge"
            android:textColor="?android:attr/textColorPrimary"
            android:ellipsize="marquee"
            android:fadingEdge="horizontal" />
​
        <TextView android:id="@android:id/summary"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_below="@android:id/title"
            android:layout_alignStart="@android:id/title"
            android:layout_alignLeft="@android:id/title"
            android:textAppearance="?android:attr/textAppearanceSmall"
            android:textColor="?android:attr/textColorSecondary"
            android:maxLines="4" />
​
    </RelativeLayout>
​
    <!-- Preference should place its actual preference widget here. -->
    <LinearLayout android:id="@android:id/widget_frame"
        android:layout_width="wrap_content"
        android:layout_height="match_parent"
        android:gravity="center_vertical"
        android:orientation="vertical" />
​
</LinearLayout> 
           

到这,布局来源的事就ok了,接着回到PreferenceGroupAdapter.onCreateViewHolder(),这里主要就是填充出上面这个布局,如果有widgetLayout的话,在加到上面id为widget_frame的LinearLayout布局中去,执行完后就看看onBindViewHolder()了:

public void onBindViewHolder(@NonNull PreferenceViewHolder holder, int position) {
    final Preference preference = getItem(position);
    preference.onBindViewHolder(holder);
}
           

view的处理又回到具体的preference了,至此,页面的显示的逻辑就差不多了。熟悉了这整个流程,想要定制自己的设置界面,那就比较简单了。比如原生的SwitchPreferenceCompat样式太丑,想要修改switch的样式,那就可以在xml布局中定义widgetLayout,如果想要整体替换,那就定义layout属性了,这里要注意一点,如果要使用xml中配置的title等属性,View使用的id就要和preference.xml中一样,不然就需要继承Preference自己去处理了。