/* * Copyright 2018 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package androidx.preference; import static androidx.annotation.RestrictTo.Scope.LIBRARY; import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX; import android.content.Context; import android.content.res.TypedArray; import android.os.Bundle; import android.os.Handler; import android.os.Parcel; import android.os.Parcelable; import android.text.TextUtils; import android.util.AttributeSet; import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.RestrictTo; import androidx.collection.SimpleArrayMap; import androidx.core.content.res.TypedArrayUtils; import androidx.recyclerview.widget.RecyclerView; import java.util.ArrayList; import java.util.Collections; import java.util.List; /** * A container for multiple {@link Preference}s. It is a base class for preference * objects that are parents, such as {@link PreferenceCategory} and {@link PreferenceScreen}. * *
*

Developer Guides

*

For information about building a settings screen using the AndroidX Preference library, see * Settings.

*
* * @attr name android:orderingFromXml * @attr name initialExpandedChildrenCount */ public abstract class PreferenceGroup extends Preference { private static final String TAG = "PreferenceGroup"; @SuppressWarnings("WeakerAccess") /* synthetic access */ final SimpleArrayMap mIdRecycleCache = new SimpleArrayMap<>(); private final Handler mHandler = new Handler(); /** * The container for child {@link Preference}s. This is sorted based on the ordering, please * use {@link #addPreference(Preference)} instead of adding to this directly. */ private List mPreferences; private boolean mOrderingAsAdded = true; private int mCurrentPreferenceOrder = 0; private boolean mAttachedToHierarchy = false; private int mInitialExpandedChildrenCount = Integer.MAX_VALUE; private OnExpandButtonClickListener mOnExpandButtonClickListener = null; private final Runnable mClearRecycleCacheRunnable = new Runnable() { @Override public void run() { synchronized (this) { mIdRecycleCache.clear(); } } }; public PreferenceGroup(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { super(context, attrs, defStyleAttr, defStyleRes); mPreferences = new ArrayList<>(); final TypedArray a = context.obtainStyledAttributes( attrs, R.styleable.PreferenceGroup, defStyleAttr, defStyleRes); mOrderingAsAdded = TypedArrayUtils.getBoolean(a, R.styleable.PreferenceGroup_orderingFromXml, R.styleable.PreferenceGroup_orderingFromXml, true); if (a.hasValue(R.styleable.PreferenceGroup_initialExpandedChildrenCount)) { setInitialExpandedChildrenCount((TypedArrayUtils.getInt( a, R.styleable.PreferenceGroup_initialExpandedChildrenCount, R.styleable.PreferenceGroup_initialExpandedChildrenCount, Integer.MAX_VALUE))); } a.recycle(); } public PreferenceGroup(Context context, AttributeSet attrs, int defStyleAttr) { this(context, attrs, defStyleAttr, 0); } public PreferenceGroup(Context context, AttributeSet attrs) { this(context, attrs, 0); } /** * Whether to order the {@link Preference} children of this group as they are added. If this * is false, the ordering will follow each Preference order and default to alphabetic for * those without an order. * *

If this is called after preferences are added, they will not be re-ordered in the * order they were added, hence call this method early on. * * @param orderingAsAdded Whether to order according to the order added * @see Preference#setOrder(int) */ public void setOrderingAsAdded(boolean orderingAsAdded) { mOrderingAsAdded = orderingAsAdded; } /** * Whether this group is ordering preferences in the order they are added. * * @return Whether this group orders based on the order the children are added * @see #setOrderingAsAdded(boolean) */ public boolean isOrderingAsAdded() { return mOrderingAsAdded; } /** * Sets the maximal number of children that are shown when the preference group is launched * where the rest of the children will be hidden. If some children are hidden an expand * button will be provided to show all the hidden children. Any child in any level of the * hierarchy that is also a preference group (e.g. preference category) will not be counted * towards the limit. But instead the children of such group will be counted. By default, all * children will be shown, so the default value of this attribute is equal to Integer.MAX_VALUE. * *

Note: The group should have a key defined if an expandable preference is present to * correctly persist state. * * @param expandedCount The number of children that is initially shown * {@link androidx.preference.R.attr#initialExpandedChildrenCount} */ public void setInitialExpandedChildrenCount(int expandedCount) { if (expandedCount != Integer.MAX_VALUE && !hasKey()) { Log.e(TAG, getClass().getSimpleName() + " should have a key defined if it contains an expandable preference"); } mInitialExpandedChildrenCount = expandedCount; } /** * Gets the maximal number of children that are initially shown. * * @return The maximal number of children that are initially shown * {@link androidx.preference.R.attr#initialExpandedChildrenCount} */ public int getInitialExpandedChildrenCount() { return mInitialExpandedChildrenCount; } /** * Called by the inflater to add an item to this group. */ public void addItemFromInflater(Preference preference) { addPreference(preference); } /** * Returns the number of children {@link Preference}s. * * @return The number of preference children in this group */ public int getPreferenceCount() { return mPreferences.size(); } /** * Returns the {@link Preference} at a particular index. * * @param index The index of the {@link Preference} to retrieve * @return The {@link Preference} */ public Preference getPreference(int index) { return mPreferences.get(index); } /** * Adds a {@link Preference} at the correct position based on the preference's order. * * @param preference The preference to add * @return Whether the preference is now in this group */ public boolean addPreference(Preference preference) { if (mPreferences.contains(preference)) { return true; } if (preference.getKey() != null) { PreferenceGroup root = this; while (root.getParent() != null) { root = root.getParent(); } final String key = preference.getKey(); if (root.findPreference(key) != null) { Log.e(TAG, "Found duplicated key: \"" + key + "\". This can cause unintended behaviour," + " please use unique keys for every preference."); } } if (preference.getOrder() == DEFAULT_ORDER) { if (mOrderingAsAdded) { preference.setOrder(mCurrentPreferenceOrder++); } if (preference instanceof PreferenceGroup) { // TODO: fix (method is called tail recursively when inflating, // so we won't end up properly passing this flag down to children ((PreferenceGroup) preference).setOrderingAsAdded(mOrderingAsAdded); } } int insertionIndex = Collections.binarySearch(mPreferences, preference); if (insertionIndex < 0) { insertionIndex = insertionIndex * -1 - 1; } if (!onPrepareAddPreference(preference)) { return false; } synchronized (this) { mPreferences.add(insertionIndex, preference); } final PreferenceManager preferenceManager = getPreferenceManager(); final String key = preference.getKey(); final long id; if (key != null && mIdRecycleCache.containsKey(key)) { id = mIdRecycleCache.get(key); mIdRecycleCache.remove(key); } else { id = preferenceManager.getNextId(); } preference.onAttachedToHierarchy(preferenceManager, id); preference.assignParent(this); if (mAttachedToHierarchy) { preference.onAttached(); } notifyHierarchyChanged(); return true; } /** * Removes a {@link Preference} from this group. * *

Note: This action is not recursive, and will only remove a preference if it exists in * this group, ignoring preferences found in nested groups. Use * {@link #removePreferenceRecursively(CharSequence)} to recursively find and remove a * preference. * * @param preference The preference to remove * @return Whether the preference was found and removed * @see #removePreferenceRecursively(CharSequence) */ public boolean removePreference(Preference preference) { final boolean returnValue = removePreferenceInt(preference); notifyHierarchyChanged(); return returnValue; } /** * Recursively finds and removes a {@link Preference} from this group or a nested group lower * down in the hierarchy. If two {@link Preference}s share the same key (not recommended), * the first to appear will be removed. * * @param key The key of the preference to remove * @return Whether the preference was found and removed * @see #findPreference(CharSequence) */ public boolean removePreferenceRecursively(@NonNull CharSequence key) { final Preference preference = findPreference(key); if (preference == null) { return false; } return preference.getParent().removePreference(preference); } private boolean removePreferenceInt(Preference preference) { synchronized (this) { preference.onPrepareForRemoval(); if (preference.getParent() == this) { preference.assignParent(null); } boolean success = mPreferences.remove(preference); if (success) { // If this preference, or another preference with the same key, gets re-added // immediately, we want it to have the same id so that it can be correctly tracked // in the adapter by RecyclerView, to make it appear as if it has only been // seamlessly updated. If the preference is not re-added by the time the handler // runs, we take that as a signal that the preference will not be re-added soon // in which case it does not need to retain the same id. // If two (or more) preferences have the same (or null) key and both are removed // and then re-added, only one id will be recycled and the second (and later) // preferences will receive a newly generated id. This use pattern of the preference // API is strongly discouraged. final String key = preference.getKey(); if (key != null) { mIdRecycleCache.put(key, preference.getId()); mHandler.removeCallbacks(mClearRecycleCacheRunnable); mHandler.post(mClearRecycleCacheRunnable); } if (mAttachedToHierarchy) { preference.onDetached(); } } return success; } } /** * Removes all {@link Preference}s from this group. */ public void removeAll() { synchronized (this) { List preferences = mPreferences; for (int i = preferences.size() - 1; i >= 0; i--) { removePreferenceInt(preferences.get(0)); } } notifyHierarchyChanged(); } /** * Prepares a {@link Preference} to be added to the group. * * @param preference The preference to add * @return Whether to allow adding the preference ({@code true}), or not ({@code false}) */ protected boolean onPrepareAddPreference(Preference preference) { preference.onParentChanged(this, shouldDisableDependents()); return true; } /** * Finds a {@link Preference} based on its key. If two {@link Preference}s share the same key * (not recommended), the first to appear will be returned. * *

This will recursively search for the {@link Preference} in any children that are also * {@link PreferenceGroup}s. * * @param key The key of the {@link Preference} to retrieve * @return The {@link Preference} with the key, or {@code null} */ @SuppressWarnings({"TypeParameterUnusedInFormals", "unchecked"}) @Nullable public T findPreference(@NonNull CharSequence key) { if (key == null) { throw new IllegalArgumentException("Key cannot be null"); } if (TextUtils.equals(getKey(), key)) { return (T) this; } final int preferenceCount = getPreferenceCount(); for (int i = 0; i < preferenceCount; i++) { final Preference preference = getPreference(i); final String curKey = preference.getKey(); if (TextUtils.equals(curKey, key)) { return (T) preference; } if (preference instanceof PreferenceGroup) { final T returnedPreference = ((PreferenceGroup) preference).findPreference(key); if (returnedPreference != null) { return returnedPreference; } } } return null; } /** * Whether this preference group should be shown on the same screen as its contained * preferences. * * @return {@code true} if the contained preferences should be shown on the same screen as this * preference. */ protected boolean isOnSameScreenAsChildren() { return true; } /** * Returns true if we're between {@link #onAttached()} and {@link #onPrepareForRemoval()} * * @hide */ @RestrictTo(LIBRARY) public boolean isAttached() { return mAttachedToHierarchy; } /** * Sets the callback to be invoked when the expand button is clicked. * * Used by Settings. * * @param onExpandButtonClickListener The callback to be invoked * @see #setInitialExpandedChildrenCount(int) * @hide */ @RestrictTo(LIBRARY_GROUP_PREFIX) public void setOnExpandButtonClickListener( @Nullable OnExpandButtonClickListener onExpandButtonClickListener) { mOnExpandButtonClickListener = onExpandButtonClickListener; } /** * Returns the callback to be invoked when the expand button is clicked. * * Used by Settings. * * @return The callback to be invoked when the expand button is clicked. * @hide */ @RestrictTo(LIBRARY_GROUP_PREFIX) @Nullable public OnExpandButtonClickListener getOnExpandButtonClickListener() { return mOnExpandButtonClickListener; } @Override public void onAttached() { super.onAttached(); // Mark as attached so if a preference is later added to this group, we // can tell it we are already attached mAttachedToHierarchy = true; // Dispatch to all contained preferences final int preferenceCount = getPreferenceCount(); for (int i = 0; i < preferenceCount; i++) { getPreference(i).onAttached(); } } @Override public void onDetached() { super.onDetached(); // We won't be attached to the activity anymore mAttachedToHierarchy = false; // Dispatch to all contained preferences final int preferenceCount = getPreferenceCount(); for (int i = 0; i < preferenceCount; i++) { getPreference(i).onDetached(); } } @Override public void notifyDependencyChange(boolean disableDependents) { super.notifyDependencyChange(disableDependents); // Child preferences have an implicit dependency on their containing // group. Dispatch dependency change to all contained preferences. final int preferenceCount = getPreferenceCount(); for (int i = 0; i < preferenceCount; i++) { getPreference(i).onParentChanged(this, disableDependents); } } void sortPreferences() { synchronized (this) { Collections.sort(mPreferences); } } @Override protected void dispatchSaveInstanceState(Bundle container) { super.dispatchSaveInstanceState(container); // Dispatch to all contained preferences final int preferenceCount = getPreferenceCount(); for (int i = 0; i < preferenceCount; i++) { getPreference(i).dispatchSaveInstanceState(container); } } @Override protected void dispatchRestoreInstanceState(Bundle container) { super.dispatchRestoreInstanceState(container); // Dispatch to all contained preferences final int preferenceCount = getPreferenceCount(); for (int i = 0; i < preferenceCount; i++) { getPreference(i).dispatchRestoreInstanceState(container); } } @Override protected Parcelable onSaveInstanceState() { final Parcelable superState = super.onSaveInstanceState(); return new SavedState(superState, mInitialExpandedChildrenCount); } @Override protected void onRestoreInstanceState(Parcelable state) { if (state == null || !state.getClass().equals(SavedState.class)) { // Didn't save state for us in saveInstanceState super.onRestoreInstanceState(state); return; } SavedState groupState = (SavedState) state; mInitialExpandedChildrenCount = groupState.mInitialExpandedChildrenCount; super.onRestoreInstanceState(groupState.getSuperState()); } /** * Interface for PreferenceGroup adapters to implement so that * {@link PreferenceFragmentCompat#scrollToPreference(String)} and * {@link PreferenceFragmentCompat#scrollToPreference(Preference)} * can determine the correct scroll position to request. */ public interface PreferencePositionCallback { /** * Returns the adapter position of the first {@link Preference} with the specified key. * * @param key Key of {@link Preference} to find * @return Adapter position of the {@link Preference} or {@link RecyclerView#NO_POSITION} * if not found */ int getPreferenceAdapterPosition(String key); /** * Returns the adapter position of the specified {@link Preference} object * * @param preference {@link Preference} object to find * @return Adapter position of the {@link Preference} or {@link RecyclerView#NO_POSITION} * if not found */ int getPreferenceAdapterPosition(Preference preference); } /** * Definition for a callback to be invoked when the expand button is clicked. * * Used by Settings. * * @see #setInitialExpandedChildrenCount(int) * @hide */ @RestrictTo(LIBRARY_GROUP_PREFIX) public interface OnExpandButtonClickListener { /** * Called when the expand button is clicked. */ void onExpandButtonClick(); } /** * A class for managing the instance state of a {@link PreferenceGroup}. */ static class SavedState extends Preference.BaseSavedState { public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { @Override public SavedState createFromParcel(Parcel in) { return new SavedState(in); } @Override public SavedState[] newArray(int size) { return new SavedState[size]; } }; int mInitialExpandedChildrenCount; SavedState(Parcel source) { super(source); mInitialExpandedChildrenCount = source.readInt(); } SavedState(Parcelable superState, int initialExpandedChildrenCount) { super(superState); mInitialExpandedChildrenCount = initialExpandedChildrenCount; } @Override public void writeToParcel(Parcel dest, int flags) { super.writeToParcel(dest, flags); dest.writeInt(mInitialExpandedChildrenCount); } } }