blob: b3ec39f4f40ac2f0e0f17667cadc6b7200a7c90a [file] [log] [blame]
/*
* Copyright (C) 2021 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.systemui.qs.tileimpl
import android.animation.ArgbEvaluator
import android.animation.PropertyValuesHolder
import android.animation.ValueAnimator
import android.content.Context
import android.content.res.ColorStateList
import android.content.res.Configuration
import android.content.res.Resources.ID_NULL
import android.graphics.drawable.Drawable
import android.graphics.drawable.RippleDrawable
import android.service.quicksettings.Tile
import android.text.TextUtils
import android.util.Log
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityNodeInfo
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.Switch
import android.widget.TextView
import com.android.settingslib.Utils
import com.android.systemui.FontSizeUtils
import com.android.systemui.R
import com.android.systemui.plugins.qs.QSIconView
import com.android.systemui.plugins.qs.QSTile
import com.android.systemui.plugins.qs.QSTile.BooleanState
import com.android.systemui.plugins.qs.QSTileView
import com.android.systemui.qs.tileimpl.QSIconViewImpl.QS_ANIM_LENGTH
import java.util.Objects
private const val TAG = "QSTileViewImpl"
open class QSTileViewImpl @JvmOverloads constructor(
context: Context,
private val _icon: QSIconView,
private val collapsed: Boolean = false
) : QSTileView(context), HeightOverrideable {
companion object {
private const val INVALID = -1
private const val BACKGROUND_NAME = "background"
private const val LABEL_NAME = "label"
private const val SECONDARY_LABEL_NAME = "secondaryLabel"
private const val CHEVRON_NAME = "chevron"
}
override var heightOverride: Int = HeightOverrideable.NO_OVERRIDE
private val colorActive = Utils.getColorAttrDefaultColor(context,
com.android.internal.R.attr.colorAccentPrimary)
private val colorInactive = Utils.getColorAttrDefaultColor(context, R.attr.offStateColor)
private val colorUnavailable =
Utils.getColorAttrDefaultColor(context, android.R.attr.colorBackground)
private val colorLabelActive =
Utils.getColorAttrDefaultColor(context, android.R.attr.textColorPrimaryInverse)
private val colorLabelInactive =
Utils.getColorAttrDefaultColor(context, android.R.attr.textColorPrimary)
private val colorLabelUnavailable =
Utils.getColorAttrDefaultColor(context, android.R.attr.textColorTertiary)
private lateinit var label: TextView
protected lateinit var secondaryLabel: TextView
private lateinit var labelContainer: IgnorableChildLinearLayout
protected lateinit var sideView: ViewGroup
private lateinit var customDrawableView: ImageView
private lateinit var chevronView: ImageView
protected var showRippleEffect = true
private lateinit var ripple: RippleDrawable
private lateinit var colorBackgroundDrawable: Drawable
private var paintColor: Int = 0
private val singleAnimator: ValueAnimator = ValueAnimator().apply {
setDuration(QS_ANIM_LENGTH)
addUpdateListener { animation ->
setAllColors(
// These casts will throw an exception if some property is missing. We should
// always have all properties.
animation.getAnimatedValue(BACKGROUND_NAME) as Int,
animation.getAnimatedValue(LABEL_NAME) as Int,
animation.getAnimatedValue(SECONDARY_LABEL_NAME) as Int,
animation.getAnimatedValue(CHEVRON_NAME) as Int
)
}
}
private var accessibilityClass: String? = null
private var stateDescriptionDeltas: CharSequence? = null
private var lastStateDescription: CharSequence? = null
private var tileState = false
private var lastState = INVALID
private val locInScreen = IntArray(2)
init {
setId(generateViewId())
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL or Gravity.START
importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_YES
clipChildren = false
clipToPadding = false
isFocusable = true
background = createTileBackground()
setColor(getBackgroundColorForState(QSTile.State.DEFAULT_STATE))
val padding = resources.getDimensionPixelSize(R.dimen.qs_tile_padding)
val startPadding = resources.getDimensionPixelSize(R.dimen.qs_tile_start_padding)
setPaddingRelative(startPadding, padding, padding, padding)
val iconSize = resources.getDimensionPixelSize(R.dimen.qs_icon_size)
addView(_icon, LayoutParams(iconSize, iconSize))
createAndAddLabels()
createAndAddSideView()
}
override fun onConfigurationChanged(newConfig: Configuration?) {
super.onConfigurationChanged(newConfig)
updateResources()
}
fun updateResources() {
FontSizeUtils.updateFontSize(label, R.dimen.qs_tile_text_size)
FontSizeUtils.updateFontSize(secondaryLabel, R.dimen.qs_tile_text_size)
val iconSize = context.resources.getDimensionPixelSize(R.dimen.qs_icon_size)
_icon.layoutParams.apply {
height = iconSize
width = iconSize
}
val padding = resources.getDimensionPixelSize(R.dimen.qs_tile_padding)
val startPadding = resources.getDimensionPixelSize(R.dimen.qs_tile_start_padding)
setPaddingRelative(startPadding, padding, padding, padding)
val labelMargin = resources.getDimensionPixelSize(R.dimen.qs_label_container_margin)
(labelContainer.layoutParams as MarginLayoutParams).apply {
marginStart = labelMargin
}
(sideView.layoutParams as MarginLayoutParams).apply {
marginStart = labelMargin
}
(chevronView.layoutParams as MarginLayoutParams).apply {
height = iconSize
width = iconSize
}
val endMargin = resources.getDimensionPixelSize(R.dimen.qs_drawable_end_margin)
(customDrawableView.layoutParams as MarginLayoutParams).apply {
height = iconSize
marginEnd = endMargin
}
}
private fun createAndAddLabels() {
labelContainer = LayoutInflater.from(context)
.inflate(R.layout.qs_tile_label, this, false) as IgnorableChildLinearLayout
label = labelContainer.requireViewById(R.id.tile_label)
secondaryLabel = labelContainer.requireViewById(R.id.app_label)
if (collapsed) {
labelContainer.ignoreLastView = true
secondaryLabel.alpha = 0f
}
setLabelColor(getLabelColorForState(QSTile.State.DEFAULT_STATE))
setSecondaryLabelColor(getSecondaryLabelColorForState(QSTile.State.DEFAULT_STATE))
addView(labelContainer)
}
private fun createAndAddSideView() {
sideView = LayoutInflater.from(context)
.inflate(R.layout.qs_tile_side_icon, this, false) as ViewGroup
customDrawableView = sideView.requireViewById(R.id.customDrawable)
chevronView = sideView.requireViewById(R.id.chevron)
setChevronColor(getChevronColorForState(QSTile.State.DEFAULT_STATE))
addView(sideView)
}
fun createTileBackground(): Drawable {
ripple = mContext.getDrawable(R.drawable.qs_tile_background) as RippleDrawable
colorBackgroundDrawable = ripple.findDrawableByLayerId(R.id.background)
return ripple
}
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
super.onLayout(changed, l, t, r, b)
if (heightOverride != HeightOverrideable.NO_OVERRIDE) {
bottom = top + heightOverride
}
}
override fun updateAccessibilityOrder(previousView: View?): View {
accessibilityTraversalAfter = previousView?.id ?: ID_NULL
return this
}
override fun getIcon(): QSIconView {
return _icon
}
override fun getIconWithBackground(): View {
return icon
}
override fun init(tile: QSTile) {
init(
{ v: View? -> tile.click(this) },
{ view: View? ->
tile.longClick(this)
true
}
)
}
private fun init(
click: OnClickListener?,
longClick: OnLongClickListener?
) {
setOnClickListener(click)
onLongClickListener = longClick
}
override fun onStateChanged(state: QSTile.State) {
post {
handleStateChanged(state)
}
}
override fun getDetailY(): Int {
return top + height / 2
}
override fun hasOverlappingRendering(): Boolean {
// Avoid layers for this layout - we don't need them.
return false
}
override fun setClickable(clickable: Boolean) {
super.setClickable(clickable)
background = if (clickable && showRippleEffect) {
ripple.also {
// In case that the colorBackgroundDrawable was used as the background, make sure
// it has the correct callback instead of null
colorBackgroundDrawable.callback = it
}
} else {
colorBackgroundDrawable
}
}
override fun getLabelContainer(): View {
return labelContainer
}
override fun getSecondaryLabel(): View {
return secondaryLabel
}
override fun getSecondaryIcon(): View {
return sideView
}
// Accessibility
override fun onInitializeAccessibilityEvent(event: AccessibilityEvent) {
super.onInitializeAccessibilityEvent(event)
if (!TextUtils.isEmpty(accessibilityClass)) {
event.className = accessibilityClass
}
if (event.contentChangeTypes == AccessibilityEvent.CONTENT_CHANGE_TYPE_STATE_DESCRIPTION &&
stateDescriptionDeltas != null) {
event.text.add(stateDescriptionDeltas)
stateDescriptionDeltas = null
}
}
override fun onInitializeAccessibilityNodeInfo(info: AccessibilityNodeInfo) {
super.onInitializeAccessibilityNodeInfo(info)
// Clear selected state so it is not announce by talkback.
info.isSelected = false
if (!TextUtils.isEmpty(accessibilityClass)) {
info.className = accessibilityClass
if (Switch::class.java.name == accessibilityClass) {
val label = resources.getString(
if (tileState) R.string.switch_bar_on else R.string.switch_bar_off)
// Set the text here for tests in
// android.platform.test.scenario.sysui.quicksettings. Can be removed when
// UiObject2 has a new getStateDescription() API and tests are updated.
info.text = label
info.isChecked = tileState
info.isCheckable = true
if (isLongClickable) {
info.addAction(
AccessibilityNodeInfo.AccessibilityAction(
AccessibilityNodeInfo.AccessibilityAction.ACTION_LONG_CLICK.id,
resources.getString(
R.string.accessibility_long_click_tile)))
}
}
}
}
override fun toString(): String {
val sb = StringBuilder(javaClass.simpleName).append('[')
sb.append("locInScreen=(${locInScreen[0]}, ${locInScreen[1]})")
sb.append(", iconView=$_icon")
sb.append(", tileState=$tileState")
sb.append("]")
return sb.toString()
}
// HANDLE STATE CHANGES RELATED METHODS
protected open fun handleStateChanged(state: QSTile.State) {
val allowAnimations = animationsEnabled()
showRippleEffect = state.showRippleEffect
isClickable = state.state != Tile.STATE_UNAVAILABLE
isLongClickable = state.handlesLongClick
icon.setIcon(state, allowAnimations)
contentDescription = state.contentDescription
// State handling and description
val stateDescription = StringBuilder()
val stateText = getStateText(state)
if (!TextUtils.isEmpty(stateText)) {
stateDescription.append(stateText)
if (TextUtils.isEmpty(state.secondaryLabel)) {
state.secondaryLabel = stateText
}
}
if (!TextUtils.isEmpty(state.stateDescription)) {
stateDescription.append(", ")
stateDescription.append(state.stateDescription)
if (lastState != INVALID && state.state == lastState &&
state.stateDescription != lastStateDescription) {
stateDescriptionDeltas = state.stateDescription
}
}
setStateDescription(stateDescription.toString())
lastStateDescription = state.stateDescription
accessibilityClass = if (state.state == Tile.STATE_UNAVAILABLE) {
null
} else {
state.expandedAccessibilityClassName
}
if (state is BooleanState) {
val newState = state.value
if (tileState != newState) {
tileState = newState
}
}
//
// Labels
if (!Objects.equals(label.text, state.label)) {
label.text = state.label
}
if (!Objects.equals(secondaryLabel.text, state.secondaryLabel)) {
secondaryLabel.text = state.secondaryLabel
secondaryLabel.visibility = if (TextUtils.isEmpty(state.secondaryLabel)) {
GONE
} else {
VISIBLE
}
}
// Colors
if (state.state != lastState) {
singleAnimator.cancel()
if (allowAnimations) {
singleAnimator.setValues(
colorValuesHolder(
BACKGROUND_NAME,
paintColor,
getBackgroundColorForState(state.state)
),
colorValuesHolder(
LABEL_NAME,
label.currentTextColor,
getLabelColorForState(state.state)
),
colorValuesHolder(
SECONDARY_LABEL_NAME,
label.currentTextColor,
getSecondaryLabelColorForState(state.state)
),
colorValuesHolder(
CHEVRON_NAME,
chevronView.imageTintList?.defaultColor ?: 0,
getChevronColorForState(state.state)
)
)
singleAnimator.start()
} else {
setAllColors(
getBackgroundColorForState(state.state),
getLabelColorForState(state.state),
getLabelColorForState(state.state),
getChevronColorForState(state.state)
)
}
}
// Right side icon
loadSideViewDrawableIfNecessary(state)
label.isEnabled = !state.disabledByPolicy
lastState = state.state
}
private fun setAllColors(
backgroundColor: Int,
labelColor: Int,
secondaryLabelColor: Int,
chevronColor: Int
) {
setColor(backgroundColor)
setLabelColor(labelColor)
setSecondaryLabelColor(secondaryLabelColor)
setChevronColor(chevronColor)
}
private fun setColor(color: Int) {
colorBackgroundDrawable.setTint(color)
paintColor = color
}
private fun setLabelColor(color: Int) {
label.setTextColor(color)
}
private fun setSecondaryLabelColor(color: Int) {
secondaryLabel.setTextColor(color)
}
private fun setChevronColor(color: Int) {
chevronView.imageTintList = ColorStateList.valueOf(color)
}
private fun loadSideViewDrawableIfNecessary(state: QSTile.State) {
if (state.sideViewCustomDrawable != null) {
customDrawableView.setImageDrawable(state.sideViewCustomDrawable)
customDrawableView.visibility = VISIBLE
chevronView.visibility = GONE
} else if (state !is BooleanState || state.forceExpandIcon) {
customDrawableView.setImageDrawable(null)
customDrawableView.visibility = GONE
chevronView.visibility = VISIBLE
} else {
customDrawableView.setImageDrawable(null)
customDrawableView.visibility = GONE
chevronView.visibility = GONE
}
}
private fun getStateText(state: QSTile.State): String {
return if (state.disabledByPolicy) {
context.getString(R.string.tile_disabled)
} else if (state.state == Tile.STATE_UNAVAILABLE) {
context.getString(R.string.tile_unavailable)
} else if (state is BooleanState) {
if (state.state == Tile.STATE_INACTIVE) {
context.getString(R.string.switch_bar_off)
} else {
context.getString(R.string.switch_bar_on)
}
} else {
""
}
}
/*
* The view should not be animated if it's not on screen and no part of it is visible.
*/
protected open fun animationsEnabled(): Boolean {
if (!isShown) {
return false
}
if (alpha != 1f) {
return false
}
getLocationOnScreen(locInScreen)
return locInScreen.get(1) >= -height
}
private fun getBackgroundColorForState(state: Int): Int {
return when (state) {
Tile.STATE_ACTIVE -> colorActive
Tile.STATE_INACTIVE -> colorInactive
Tile.STATE_UNAVAILABLE -> colorUnavailable
else -> {
Log.e(TAG, "Invalid state $state")
0
}
}
}
private fun getLabelColorForState(state: Int): Int {
return when (state) {
Tile.STATE_ACTIVE -> colorLabelActive
Tile.STATE_INACTIVE -> colorLabelInactive
Tile.STATE_UNAVAILABLE -> colorLabelUnavailable
else -> {
Log.e(TAG, "Invalid state $state")
0
}
}
}
private fun getSecondaryLabelColorForState(state: Int): Int {
return when (state) {
Tile.STATE_ACTIVE -> colorLabelActive
Tile.STATE_INACTIVE, Tile.STATE_UNAVAILABLE -> colorLabelUnavailable
else -> {
Log.e(TAG, "Invalid state $state")
0
}
}
}
private fun getChevronColorForState(state: Int): Int = getSecondaryLabelColorForState(state)
}
private fun colorValuesHolder(name: String, vararg values: Int): PropertyValuesHolder {
return PropertyValuesHolder.ofInt(name, *values).apply {
setEvaluator(ArgbEvaluator.getInstance())
}
}