Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 94c6cf3c authored by Jay Thomas Sullivan's avatar Jay Thomas Sullivan Committed by Yi-an Chen
Browse files

[XPD] Support DrawableStateLayout in SettingsPreferenceGroupAdapter

SettingsPreferenceGroupAdapter adds background images to Preferences:
to give the look of rounded corners. Currently the way this works is
by dynamically and conditionally adding one of several background
images to the preference (the condition being whether the preference is
top, middle, bottom, etc of a preference group), where the background
image presents rounded corners.

But this CL changes the approach: by leveraging StateListDrawable, we
can squash all of these images into a single background image, and what
the image will actually look like depends on a "state" that we also
apply to the preference.

The advantage of using a single image is that it makes it easier to
customize the background image: where before it was hardcoded to one of
several backgrounds, it's now a matter of changing the 'background'
attribute.

For 'background' to take effect, the root element of a layout must
implement the interface DrawableStateLayout (defined in this CL), which
represents a layout which accepts a state ("extraDrawableState"). Or,
use DrawableStateLinearLayout which already implements the interface.

Finally, if a preference doesn't impl DrawableStateLayout, then we'll
force a hardcoded background and padding on it. (Which is how we've
been handling this prior to this change.)

(Note: some of the code used here to SettingsPreferenceGroupAdapter
was originally copied from PermissionController's
SectionPreferenceGroupAdapter, which already had this solution.)

Test: manual
Relnote: N/A
Flag: com.android.settingslib.widget.theme.flags.is_expressive_design_enabled
Bug: 375480009
Change-Id: I059589aea414e7b7284a060badfc9e3ccf888ea6
parent 73e783d4
Loading
Loading
Loading
Loading
+21 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 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 com.android.settingslib.widget

interface DrawableStateLayout {
    var extraDrawableState: IntArray?
}
 No newline at end of file
+60 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 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 com.android.settingslib.widget

import android.content.Context
import android.util.AttributeSet
import android.widget.LinearLayout
import androidx.annotation.AttrRes
import androidx.annotation.StyleRes

/** This is a simple wrapper for [LinearLayout] that allows setting an extra drawable state. */
class DrawableStateLinearLayout : LinearLayout, DrawableStateLayout {
    override var extraDrawableState: IntArray? = null
        set(value) {
            if (field != value) {
                field = value
                refreshDrawableState()
            }
        }

    constructor(context: Context) : super(context)

    constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)

    constructor(
        context: Context,
        attrs: AttributeSet?,
        @AttrRes defStyleAttr: Int
    ) : super(context, attrs, defStyleAttr)

    constructor(
        context: Context,
        attrs: AttributeSet?,
        @AttrRes defStyleAttr: Int,
        @StyleRes defStyleRes: Int
    ) : super(context, attrs, defStyleAttr, defStyleRes)

    override fun onCreateDrawableState(extraSpace: Int): IntArray {
        val extraDrawableState =
            extraDrawableState ?: return super.onCreateDrawableState(extraSpace)
        return mergeDrawableStates(
            super.onCreateDrawableState(extraSpace + extraDrawableState.size),
            extraDrawableState
        )
    }
}
+119 −138
Original line number Diff line number Diff line
@@ -29,15 +29,23 @@ import androidx.preference.PreferenceViewHolder
import com.android.settingslib.widget.theme.R

/**
 * A custom adapter for displaying settings preferences in a list, handling rounded corners for
 * preference items within a group.
 * This is an extension over [PreferenceGroupAdapter] that allows creating visual sections for
 * preferences. It sets the following drawable states on item views when they are a
 * [DrawableStateLayout]:
 * - [android.R.attr.state_single] if the item is the only one in a section
 * - [android.R.attr.state_first] if the item is the first one in a section
 * - [android.R.attr.state_middle] if the item is neither the first one or the last one in a section
 * - [android.R.attr.state_last] if the item is the last one in a section
 *
 * Note that [androidx.preference.PreferenceManager.PreferenceComparisonCallback] isn't supported
 * (yet).
 */
@SuppressLint("RestrictedApi")
open class SettingsPreferenceGroupAdapter(preferenceGroup: PreferenceGroup) :
    PreferenceGroupAdapter(preferenceGroup) {

    private val mPreferenceGroup = preferenceGroup
    private var mRoundCornerMappingList: ArrayList<Int> = ArrayList()
    private var mItemPositionStates = intArrayOf()

    private var mNormalPaddingStart = 0
    private var mGroupPaddingStart = 0
@@ -67,7 +75,6 @@ open class SettingsPreferenceGroupAdapter(preferenceGroup: PreferenceGroup) :
        updatePreferencesList()
    }

    @SuppressLint("RestrictedApi")
    override fun onPreferenceHierarchyChange(preference: Preference) {
        super.onPreferenceHierarchyChange(preference)

@@ -92,17 +99,15 @@ open class SettingsPreferenceGroupAdapter(preferenceGroup: PreferenceGroup) :
            return
        }

        val oldList = ArrayList(mRoundCornerMappingList)
        mRoundCornerMappingList = ArrayList()
        mappingPreferenceGroup(mRoundCornerMappingList, mPreferenceGroup)

        if (mRoundCornerMappingList != oldList) {
            notifyOnlyChangedItems(oldList, mRoundCornerMappingList)
        val oldItemPositionStates = mItemPositionStates
        mItemPositionStates = buildItemPositionStates()
        if (!(mItemPositionStates contentEquals oldItemPositionStates)) {
            notifyOnlyChangedItems(oldItemPositionStates, mItemPositionStates)
        }
    }

    /** Notify any registered observers if the new list's items changed. */
    private fun notifyOnlyChangedItems(oldList: ArrayList<Int>, newList: ArrayList<Int>) {
    private fun notifyOnlyChangedItems(oldList: IntArray, newList: IntArray) {
        val minLength = minOf(oldList.size, newList.size)

        for (position in 0 until minLength) {
@@ -119,81 +124,53 @@ open class SettingsPreferenceGroupAdapter(preferenceGroup: PreferenceGroup) :
        }
    }

    @SuppressLint("RestrictedApi")
    private fun mappingPreferenceGroup(cornerStyles: MutableList<Int>, group: PreferenceGroup) {
        cornerStyles.clear()
        cornerStyles.addAll(MutableList(itemCount) { 0 })

        // the first item in to group
        var startIndex = -1
        // the last item in the group
        var endIndex = -1
        var currentParent: PreferenceGroup? = group
        for (i in 0 until itemCount) {
            when (val pref = getItem(i)) {
                // the preference has round corner background, so we don't need to handle it.
                is GroupSectionDividerMixin -> {
                    cornerStyles[i] = 0
                    startIndex = -1
                    endIndex = -1
                }

                // PreferenceCategory should not have round corner background.
                is PreferenceCategory -> {
                    cornerStyles[i] = 0
                    startIndex = -1
                    endIndex = -1
                    currentParent = pref
                }

                // SpacePreference should not have round corner background.
                is SpacePreference -> {
                    cornerStyles[i] = 0
                    endIndex = endIndex - 1
                }

                else -> {
                    // When ExpandablePreference is expanded, we treat is as the first item.
                    if (pref is Expandable && pref.isExpanded()) {
                        currentParent = pref as? PreferenceGroup
                        startIndex = i
                        cornerStyles[i] = cornerStyles[i] or ROUND_CORNER_TOP or ROUND_CORNER_CENTER
                        endIndex = -1
    private fun buildItemPositionStates(): IntArray {
        val itemCount = itemCount
        val itemPositionStates = IntArray(itemCount)

        var prevItemIndex = -2
        var previousParent: Preference? = null
        var currentParent: Preference? = null
        for (i in 0..<itemCount) {
            val preference = getItem(i)!!
            // If the preference is a group divider, skip this index (resulting in new group)
            if (isGroupDivider(preference)) {
                itemPositionStates[i] = 0
                continue
            }

            // Start a new group if any of the following are true:
            //     - We're at the first index
            //     - We've skipped an index
            //     - We've hit an expanded Expandable parent
            //     - We've changed parent (except: if parent is null, or we hit an Expandable child)
            previousParent = currentParent
            currentParent = preference.parent
            val isExpandedParent = preference is Expandable && preference.isExpanded()
            val isExpandedChild = currentParent is Expandable && currentParent.isExpanded()
            val changedParent = previousParent != currentParent && currentParent != null
            if (prevItemIndex != i - 1 || isExpandedParent || (changedParent && !isExpandedChild)) {
                closeGroup(itemPositionStates, prevItemIndex)
                itemPositionStates[i] = android.R.attr.state_first
                prevItemIndex = i
            } else {

                        val parent = pref?.parent

                        // item in the group should have round corner background.
                        cornerStyles[i] = cornerStyles[i] or ROUND_CORNER_CENTER
                        // We should treat the ExpandButton as a part of the previous group
                        // despite that it doesn't have a parent.
                        if (parent === currentParent || parent == null) {
                            // find the first item in the group
                            if (startIndex == -1) {
                                startIndex = i
                                cornerStyles[i] = cornerStyles[i] or ROUND_CORNER_TOP
                            }

                            // find the last item in the group, if we find the new last item, we should
                            // remove the old last item round corner.
                            if (endIndex == -1 || endIndex < i) {
                                if (endIndex != -1) {
                                    cornerStyles[endIndex] =
                                        cornerStyles[endIndex] and ROUND_CORNER_BOTTOM.inv()
                                }
                                endIndex = i
                                cornerStyles[i] = cornerStyles[i] or ROUND_CORNER_BOTTOM
                            }
                        } else {
                            // this item is new group, we should reset the index.
                            currentParent = parent
                            startIndex = i
                            cornerStyles[i] = cornerStyles[i] or ROUND_CORNER_TOP
                            endIndex = i
                            cornerStyles[i] = cornerStyles[i] or ROUND_CORNER_BOTTOM
                // Otherwise, continue current group
                itemPositionStates[i] = android.R.attr.state_middle
                prevItemIndex = i
            }
        }
        // Close current group
        closeGroup(itemPositionStates, prevItemIndex)
        return itemPositionStates
    }

    private fun closeGroup(itemPositionStates: IntArray, i: Int) {
        if (i >= 0) {
            itemPositionStates[i] =
                when (itemPositionStates[i]) {
                    0 -> 0
                    android.R.attr.state_first -> android.R.attr.state_single
                    else -> android.R.attr.state_last
                }
        }
    }
@@ -201,26 +178,26 @@ open class SettingsPreferenceGroupAdapter(preferenceGroup: PreferenceGroup) :
    /** handle roundCorner background */
    private fun updateBackground(holder: PreferenceViewHolder, position: Int) {
        val context = holder.itemView.context
        @DrawableRes
        val backgroundRes =
            when (SettingsThemeHelper.isExpressiveTheme(context)) {
                true -> getRoundCornerDrawableRes(position, isSelected = false)
                else -> mLegacyBackgroundRes
            }

        val v = holder.itemView
        // Update padding
        if (SettingsThemeHelper.isExpressiveTheme(context)) {
            val (paddingStart, paddingEnd) = getStartEndPadding(position, backgroundRes)
            val drawableStateLayout = holder.itemView as? DrawableStateLayout
            if (drawableStateLayout != null) {
                drawableStateLayout.extraDrawableState = stateSetOf(mItemPositionStates[position])
            } else {
                val backgroundRes = getRoundCornerDrawableRes(position, isSelected = false)
                val (paddingStart, paddingEnd) = getStartEndPadding(position)
                v.setPaddingRelative(paddingStart, v.paddingTop, paddingEnd, v.paddingBottom)
                v.clipToOutline = backgroundRes != 0
        }
        // Update background
                v.setBackgroundResource(backgroundRes)
            }
        } else {
            v.setBackgroundResource(mLegacyBackgroundRes)
        }
    }

    private fun getStartEndPadding(position: Int, backgroundRes: Int): Pair<Int, Int> {
    private fun getStartEndPadding(position: Int): Pair<Int, Int> {
        val item = getItem(position)
        val positionState = mItemPositionStates[position]
        return when {
            // This item handles edge to edge itself
            item is NormalPaddingMixin && item is GroupSectionDividerMixin -> 0 to 0
@@ -231,11 +208,11 @@ open class SettingsPreferenceGroupAdapter(preferenceGroup: PreferenceGroup) :
                mNormalPaddingStart + extraPadding to mNormalPaddingEnd + extraPadding
            }

            // According to mappingPreferenceGroup(), backgroundRes == 0 means this item is
            // GroupSectionDividerMixin or PreferenceCategory, which is design to have normal
            // padding.
            // NormalPaddingMixin items are also designed to have normal padding.
            backgroundRes == 0 || item is NormalPaddingMixin ->
            // This item should have normal padding if either:
            // - this item's positionState == 0 (which denotes that it is a section divider item
            //   such as a GroupSectionDividerMixin or PreferenceCategory), or
            // - this preference is a NormalPaddingMixin.
            positionState == 0 || item is NormalPaddingMixin ->
                mNormalPaddingStart to mNormalPaddingEnd

            // Other items are suppose to have group padding.
@@ -244,60 +221,64 @@ open class SettingsPreferenceGroupAdapter(preferenceGroup: PreferenceGroup) :
    }

    @DrawableRes
    protected fun getRoundCornerDrawableRes(position: Int, isSelected: Boolean): Int {
        return getRoundCornerDrawableRes(position, isSelected, false)
    }

    @DrawableRes
    @JvmOverloads
    protected fun getRoundCornerDrawableRes(
        position: Int,
        isSelected: Boolean,
        isHighlighted: Boolean,
        isHighlighted: Boolean = false,
    ): Int {
        if (position !in mRoundCornerMappingList.indices) {
            return 0
        }

        val cornerType = mRoundCornerMappingList[position]

        if ((cornerType and ROUND_CORNER_CENTER) == 0) {
            return 0
        }

        return when {
            (cornerType and ROUND_CORNER_TOP) != 0 && (cornerType and ROUND_CORNER_BOTTOM) == 0 -> {
                // the first
        val positionState = mItemPositionStates[position]
        return when (positionState) {
            // preference is the first of the section
            android.R.attr.state_first -> {
                if (isSelected) R.drawable.settingslib_round_background_top_selected
                else if (isHighlighted) R.drawable.settingslib_round_background_top_highlighted
                else R.drawable.settingslib_round_background_top
            }

            (cornerType and ROUND_CORNER_BOTTOM) != 0 && (cornerType and ROUND_CORNER_TOP) == 0 -> {
                // the last
            // preference is in the center of the section
            android.R.attr.state_middle -> {
                if (isSelected) R.drawable.settingslib_round_background_center_selected
                else if (isHighlighted) R.drawable.settingslib_round_background_center_highlighted
                else R.drawable.settingslib_round_background_center
            }
            // preference is the last of the section
            android.R.attr.state_last -> {
                if (isSelected) R.drawable.settingslib_round_background_bottom_selected
                else if (isHighlighted) R.drawable.settingslib_round_background_bottom_highlighted
                else R.drawable.settingslib_round_background_bottom
            }

            (cornerType and ROUND_CORNER_TOP) != 0 && (cornerType and ROUND_CORNER_BOTTOM) != 0 -> {
                // the only one preference
            // preference is the only one in the section
            android.R.attr.state_single -> {
                if (isSelected) R.drawable.settingslib_round_background_selected
                else if (isHighlighted) R.drawable.settingslib_round_background_highlighted
                else R.drawable.settingslib_round_background
            }

            else -> {
                // in the center
                if (isSelected) R.drawable.settingslib_round_background_center_selected
                else if (isHighlighted) R.drawable.settingslib_round_background_center_highlighted
                else R.drawable.settingslib_round_background_center
            }
            // preference is not part of a section
            else -> 0
        }
    }

    protected fun isGroupDivider(preference: Preference) =
        preference is GroupSectionDividerMixin || preference is PreferenceCategory
                || preference is SpacePreference

    companion object {
        private const val ROUND_CORNER_CENTER: Int = 1
        private const val ROUND_CORNER_TOP: Int = 1 shl 1
        private const val ROUND_CORNER_BOTTOM: Int = 1 shl 2
        private val STATE_SET_NONE = intArrayOf()
        private val STATE_SET_SINGLE = intArrayOf(android.R.attr.state_single)
        private val STATE_SET_FIRST = intArrayOf(android.R.attr.state_first)
        private val STATE_SET_MIDDLE = intArrayOf(android.R.attr.state_middle)
        private val STATE_SET_LAST = intArrayOf(android.R.attr.state_last)

        private fun stateSetOf(
            positionState: Int
        ): IntArray =
            when {
                positionState == 0 -> STATE_SET_NONE
                positionState == android.R.attr.state_single -> STATE_SET_SINGLE
                positionState == android.R.attr.state_first -> STATE_SET_FIRST
                positionState == android.R.attr.state_middle -> STATE_SET_MIDDLE
                positionState == android.R.attr.state_last -> STATE_SET_LAST
                else -> error(positionState)
            }
    }
}