1 /*
<lambda>null2 * Copyright (C) 2021 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17 package com.android.systemui.qs.tileimpl
18
19 import android.animation.ArgbEvaluator
20 import android.animation.PropertyValuesHolder
21 import android.animation.ValueAnimator
22 import android.annotation.SuppressLint
23 import android.content.Context
24 import android.content.res.ColorStateList
25 import android.content.res.Configuration
26 import android.content.res.Resources.ID_NULL
27 import android.graphics.Color
28 import android.graphics.PorterDuff
29 import android.graphics.Rect
30 import android.graphics.drawable.Drawable
31 import android.graphics.drawable.GradientDrawable
32 import android.graphics.drawable.LayerDrawable
33 import android.graphics.drawable.RippleDrawable
34 import android.os.Trace
35 import android.service.quicksettings.Tile
36 import android.text.TextUtils
37 import android.util.Log
38 import android.util.TypedValue
39 import android.view.Gravity
40 import android.view.LayoutInflater
41 import android.view.MotionEvent
42 import android.view.View
43 import android.view.ViewConfiguration
44 import android.view.ViewGroup
45 import android.view.accessibility.AccessibilityEvent
46 import android.view.accessibility.AccessibilityNodeInfo
47 import android.view.animation.AccelerateDecelerateInterpolator
48 import android.widget.Button
49 import android.widget.ImageView
50 import android.widget.LinearLayout
51 import android.widget.Switch
52 import android.widget.TextView
53 import androidx.annotation.VisibleForTesting
54 import androidx.core.animation.doOnCancel
55 import androidx.core.animation.doOnEnd
56 import androidx.core.animation.doOnStart
57 import androidx.core.graphics.drawable.updateBounds
58 import com.android.app.tracing.traceSection
59 import com.android.settingslib.Utils
60 import com.android.systemui.Flags
61 import com.android.systemui.FontSizeUtils
62 import com.android.systemui.animation.Expandable
63 import com.android.systemui.animation.LaunchableView
64 import com.android.systemui.animation.LaunchableViewDelegate
65 import com.android.systemui.haptics.qs.QSLongPressEffect
66 import com.android.systemui.plugins.qs.QSIconView
67 import com.android.systemui.plugins.qs.QSTile
68 import com.android.systemui.plugins.qs.QSTile.AdapterState
69 import com.android.systemui.plugins.qs.QSTileView
70 import com.android.systemui.qs.logging.QSLogger
71 import com.android.systemui.qs.tileimpl.QSIconViewImpl.QS_ANIM_LENGTH
72 import com.android.systemui.res.R
73 import java.util.Objects
74
75 private const val TAG = "QSTileViewImpl"
76
77 open class QSTileViewImpl
78 @JvmOverloads
79 constructor(
80 context: Context,
81 private val collapsed: Boolean = false,
82 private val longPressEffect: QSLongPressEffect? = null,
83 ) : QSTileView(context), HeightOverrideable, LaunchableView {
84
85 companion object {
86 private const val INVALID = -1
87 private const val BACKGROUND_NAME = "background"
88 private const val LABEL_NAME = "label"
89 private const val SECONDARY_LABEL_NAME = "secondaryLabel"
90 private const val CHEVRON_NAME = "chevron"
91 private const val OVERLAY_NAME = "overlay"
92 const val UNAVAILABLE_ALPHA = 0.3f
93 @VisibleForTesting internal const val TILE_STATE_RES_PREFIX = "tile_states_"
94 @VisibleForTesting internal const val LONG_PRESS_EFFECT_WIDTH_SCALE = 1.1f
95 @VisibleForTesting internal const val LONG_PRESS_EFFECT_HEIGHT_SCALE = 1.2f
96 internal val EMPTY_RECT = Rect()
97 }
98
99 private val icon: QSIconViewImpl = QSIconViewImpl(context)
100 private var position: Int = INVALID
101
102 override fun setPosition(position: Int) {
103 this.position = position
104 }
105
106 override var heightOverride: Int = HeightOverrideable.NO_OVERRIDE
107 set(value) {
108 if (field == value) return
109 field = value
110 if (longPressEffect?.state != QSLongPressEffect.State.RUNNING_BACKWARDS_FROM_CANCEL) {
111 updateHeight()
112 }
113 }
114
115 override var squishinessFraction: Float = 1f
116 set(value) {
117 if (field == value) return
118 field = value
119 updateHeight()
120 }
121
122 private val colorActive = Utils.getColorAttrDefaultColor(context, R.attr.shadeActive)
123 private val colorInactive = Utils.getColorAttrDefaultColor(context, R.attr.shadeInactive)
124 private val colorUnavailable = Utils.getColorAttrDefaultColor(context, R.attr.shadeDisabled)
125
126 private val overlayColorActive =
127 Utils.applyAlpha(
128 /* alpha= */ 0.11f,
129 Utils.getColorAttrDefaultColor(context, R.attr.onShadeActive),
130 )
131 private val overlayColorInactive =
132 Utils.applyAlpha(
133 /* alpha= */ 0.08f,
134 Utils.getColorAttrDefaultColor(context, R.attr.onShadeInactive),
135 )
136
137 private val colorLabelActive = Utils.getColorAttrDefaultColor(context, R.attr.onShadeActive)
138 private val colorLabelInactive = Utils.getColorAttrDefaultColor(context, R.attr.onShadeInactive)
139 private val colorLabelUnavailable = Utils.getColorAttrDefaultColor(context, R.attr.outline)
140
141 private val colorSecondaryLabelActive =
142 Utils.getColorAttrDefaultColor(context, R.attr.onShadeActiveVariant)
143 private val colorSecondaryLabelInactive =
144 Utils.getColorAttrDefaultColor(context, R.attr.onShadeInactiveVariant)
145 private val colorSecondaryLabelUnavailable =
146 Utils.getColorAttrDefaultColor(context, R.attr.outline)
147
148 private lateinit var label: TextView
149 protected lateinit var secondaryLabel: TextView
150 private lateinit var labelContainer: IgnorableChildLinearLayout
151 protected lateinit var sideView: ViewGroup
152 private lateinit var customDrawableView: ImageView
153 private lateinit var chevronView: ImageView
154 private var mQsLogger: QSLogger? = null
155
156 /** Controls if tile background is set to a [RippleDrawable] see [setClickable] */
157 protected var showRippleEffect = true
158
159 private lateinit var qsTileBackground: RippleDrawable
160 private lateinit var qsTileFocusBackground: Drawable
161 private lateinit var backgroundDrawable: LayerDrawable
162 private lateinit var backgroundBaseDrawable: Drawable
163 private lateinit var backgroundOverlayDrawable: Drawable
164
165 private var backgroundColor: Int = 0
166 private var backgroundOverlayColor: Int = 0
167
168 private val singleAnimator: ValueAnimator =
169 ValueAnimator().apply {
170 setDuration(QS_ANIM_LENGTH)
171 addUpdateListener { animation ->
172 setAllColors(
173 // These casts will throw an exception if some property is missing. We should
174 // always have all properties.
175 animation.getAnimatedValue(BACKGROUND_NAME) as Int,
176 animation.getAnimatedValue(LABEL_NAME) as Int,
177 animation.getAnimatedValue(SECONDARY_LABEL_NAME) as Int,
178 animation.getAnimatedValue(CHEVRON_NAME) as Int,
179 animation.getAnimatedValue(OVERLAY_NAME) as Int,
180 )
181 }
182 }
183
184 private var accessibilityClass: String? = null
185 private var stateDescriptionDeltas: CharSequence? = null
186 private var lastStateDescription: CharSequence? = null
187 private var tileState = false
188 private var lastState = INVALID
189 private var lastIconTint = 0
190 private val launchableViewDelegate =
191 LaunchableViewDelegate(this, superSetVisibility = { super.setVisibility(it) })
192 private var lastDisabledByPolicy = false
193
194 private val locInScreen = IntArray(2)
195
196 /** Visuo-haptic long-press effects */
197 private var longPressEffectAnimator: ValueAnimator? = null
198 var haveLongPressPropertiesBeenReset = true
199 private set
200
201 private var paddingForLaunch = Rect()
202 private var initialLongPressProperties: QSLongPressProperties? = null
203 private var finalLongPressProperties: QSLongPressProperties? = null
204 private val colorEvaluator = ArgbEvaluator.getInstance()
205 val isLongPressEffectInitialized: Boolean
206 get() = longPressEffect?.hasInitialized == true
207
208 val areLongPressEffectPropertiesSet: Boolean
209 get() = initialLongPressProperties != null && finalLongPressProperties != null
210
211 init {
212 val typedValue = TypedValue()
213 if (!getContext().theme.resolveAttribute(R.attr.isQsTheme, typedValue, true)) {
214 throw IllegalStateException(
215 "QSViewImpl must be inflated with a theme that contains " +
216 "Theme.SystemUI.QuickSettings"
217 )
218 }
219 setId(generateViewId())
220 orientation = LinearLayout.HORIZONTAL
221 gravity = Gravity.CENTER_VERTICAL or Gravity.START
222 importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_YES
223 clipChildren = false
224 clipToPadding = false
225 isFocusable = true
226 background = createTileBackground()
227 setColor(getBackgroundColorForState(QSTile.State.DEFAULT_STATE))
228
229 val padding = resources.getDimensionPixelSize(R.dimen.qs_tile_padding)
230 val startPadding = resources.getDimensionPixelSize(R.dimen.qs_tile_start_padding)
231 setPaddingRelative(startPadding, padding, padding, padding)
232
233 val iconSize = resources.getDimensionPixelSize(R.dimen.qs_icon_size)
234 addView(icon, LayoutParams(iconSize, iconSize))
235
236 createAndAddLabels()
237 createAndAddSideView()
238 }
239
240 override fun onConfigurationChanged(newConfig: Configuration?) {
241 super.onConfigurationChanged(newConfig)
242 updateResources()
243 }
244
245 override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
246 Trace.traceBegin(Trace.TRACE_TAG_APP, "QSTileViewImpl#onMeasure")
247 super.onMeasure(widthMeasureSpec, heightMeasureSpec)
248 Trace.endSection()
249 }
250
251 override fun resetOverride() {
252 heightOverride = HeightOverrideable.NO_OVERRIDE
253 updateHeight()
254 }
255
256 fun setQsLogger(qsLogger: QSLogger) {
257 mQsLogger = qsLogger
258 }
259
260 fun updateResources() {
261 FontSizeUtils.updateFontSize(label, R.dimen.qs_tile_text_size)
262 FontSizeUtils.updateFontSize(secondaryLabel, R.dimen.qs_tile_text_size)
263
264 val iconSize = context.resources.getDimensionPixelSize(R.dimen.qs_icon_size)
265 icon.layoutParams.apply {
266 height = iconSize
267 width = iconSize
268 }
269
270 val padding = resources.getDimensionPixelSize(R.dimen.qs_tile_padding)
271 val startPadding = resources.getDimensionPixelSize(R.dimen.qs_tile_start_padding)
272 setPaddingRelative(startPadding, padding, padding, padding)
273
274 val labelMargin = resources.getDimensionPixelSize(R.dimen.qs_label_container_margin)
275 (labelContainer.layoutParams as MarginLayoutParams).apply { marginStart = labelMargin }
276
277 (sideView.layoutParams as MarginLayoutParams).apply { marginStart = labelMargin }
278 (chevronView.layoutParams as MarginLayoutParams).apply {
279 height = iconSize
280 width = iconSize
281 }
282
283 val endMargin = resources.getDimensionPixelSize(R.dimen.qs_drawable_end_margin)
284 (customDrawableView.layoutParams as MarginLayoutParams).apply {
285 height = iconSize
286 marginEnd = endMargin
287 }
288
289 background = createTileBackground()
290 setColor(backgroundColor)
291 setOverlayColor(backgroundOverlayColor)
292 }
293
294 private fun createAndAddLabels() {
295 labelContainer =
296 LayoutInflater.from(context).inflate(R.layout.qs_tile_label, this, false)
297 as IgnorableChildLinearLayout
298 label = labelContainer.requireViewById(R.id.tile_label)
299 secondaryLabel = labelContainer.requireViewById(R.id.app_label)
300 if (collapsed) {
301 labelContainer.ignoreLastView = true
302 // Ideally, it'd be great if the parent could set this up when measuring just this child
303 // instead of the View class having to support this. However, due to the mysteries of
304 // LinearLayout's double measure pass, we cannot overwrite `measureChild` or any of its
305 // sibling methods to have special behavior for labelContainer.
306 labelContainer.forceUnspecifiedMeasure = true
307 secondaryLabel.alpha = 0f
308 }
309 setLabelColor(getLabelColorForState(QSTile.State.DEFAULT_STATE))
310 setSecondaryLabelColor(getSecondaryLabelColorForState(QSTile.State.DEFAULT_STATE))
311 addView(labelContainer)
312 }
313
314 private fun createAndAddSideView() {
315 sideView =
316 LayoutInflater.from(context).inflate(R.layout.qs_tile_side_icon, this, false)
317 as ViewGroup
318 customDrawableView = sideView.requireViewById(R.id.customDrawable)
319 chevronView = sideView.requireViewById(R.id.chevron)
320 setChevronColor(getChevronColorForState(QSTile.State.DEFAULT_STATE))
321 addView(sideView)
322 }
323
324 private fun createTileBackground(): Drawable {
325 qsTileBackground =
326 if (Flags.qsTileFocusState()) {
327 mContext.getDrawable(R.drawable.qs_tile_background_flagged) as RippleDrawable
328 } else {
329 mContext.getDrawable(R.drawable.qs_tile_background) as RippleDrawable
330 }
331 qsTileFocusBackground = mContext.getDrawable(R.drawable.qs_tile_focused_background)!!
332 backgroundDrawable =
333 qsTileBackground.findDrawableByLayerId(R.id.background) as LayerDrawable
334 backgroundBaseDrawable =
335 backgroundDrawable.findDrawableByLayerId(R.id.qs_tile_background_base)
336 backgroundOverlayDrawable =
337 backgroundDrawable.findDrawableByLayerId(R.id.qs_tile_background_overlay)
338 backgroundOverlayDrawable.mutate().setTintMode(PorterDuff.Mode.SRC)
339 return qsTileBackground
340 }
341
342 override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
343 super.onLayout(changed, l, t, r, b)
344 updateHeight()
345 maybeUpdateLongPressEffectWidth(measuredWidth.toFloat())
346 }
347
348 private fun maybeUpdateLongPressEffectWidth(width: Float) {
349 if (!isLongClickable || longPressEffect == null) return
350
351 initialLongPressProperties?.width = width
352 finalLongPressProperties?.width = LONG_PRESS_EFFECT_WIDTH_SCALE * width
353
354 val deltaW = (LONG_PRESS_EFFECT_WIDTH_SCALE - 1f) * width
355 paddingForLaunch.left = -deltaW.toInt() / 2
356 paddingForLaunch.right = deltaW.toInt() / 2
357 }
358
359 private fun maybeUpdateLongPressEffectHeight(height: Float) {
360 if (!isLongClickable || longPressEffect == null) return
361
362 initialLongPressProperties?.height = height
363 finalLongPressProperties?.height = LONG_PRESS_EFFECT_HEIGHT_SCALE * height
364
365 val deltaH = (LONG_PRESS_EFFECT_HEIGHT_SCALE - 1f) * height
366 paddingForLaunch.top = -deltaH.toInt() / 2
367 paddingForLaunch.bottom = deltaH.toInt() / 2
368 }
369
370 override fun onFocusChanged(gainFocus: Boolean, direction: Int, previouslyFocusedRect: Rect?) {
371 super.onFocusChanged(gainFocus, direction, previouslyFocusedRect)
372 if (Flags.qsTileFocusState()) {
373 if (gainFocus) {
374 qsTileFocusBackground.setBounds(0, 0, width, height)
375 overlay.add(qsTileFocusBackground)
376 } else {
377 overlay.clear()
378 }
379 }
380 }
381
382 private fun updateHeight() {
383 val actualHeight =
384 if (heightOverride != HeightOverrideable.NO_OVERRIDE) {
385 heightOverride
386 } else {
387 measuredHeight
388 }
389 // Limit how much we affect the height, so we don't have rounding artifacts when the tile
390 // is too short.
391 val constrainedSquishiness = constrainSquishiness(squishinessFraction)
392 bottom = top + (actualHeight * constrainedSquishiness).toInt()
393 scrollY = (actualHeight - height) / 2
394 maybeUpdateLongPressEffectHeight(actualHeight.toFloat())
395 }
396
397 override fun updateAccessibilityOrder(previousView: View?): View {
398 accessibilityTraversalAfter = previousView?.id ?: ID_NULL
399 return this
400 }
401
402 override fun getIcon(): QSIconView {
403 return icon
404 }
405
406 override fun getIconWithBackground(): View {
407 return icon
408 }
409
410 override fun init(tile: QSTile) {
411 if (longPressEffect != null) {
412 isHapticFeedbackEnabled = false
413 longPressEffect.qsTile = tile
414 longPressEffect.createExpandableFromView(this)
415 initLongPressEffectCallback()
416 init(
417 { _: View -> longPressEffect.onTileClick() },
418 { _: View ->
419 longPressEffect.onTileLongClick()
420 true
421 }, // Haptics and long-clicks are handled by [QSLongPressEffect]
422 )
423 } else {
424 val expandable = Expandable.fromView(this)
425 init(
426 { _: View? -> tile.click(expandable) },
427 { _: View? ->
428 tile.longClick(expandable)
429 true
430 },
431 )
432 }
433 }
434
435 private fun initLongPressEffectCallback() {
436 longPressEffect?.callback =
437 object : QSLongPressEffect.Callback {
438
439 override fun onResetProperties() {
440 resetLongPressEffectProperties()
441 }
442
443 override fun onEffectFinishedReversing() {
444 // The long-press effect properties finished at the same starting point.
445 // This is the same as if the properties were reset
446 haveLongPressPropertiesBeenReset = true
447 }
448
449 override fun onStartAnimator() {
450 if (longPressEffectAnimator?.isRunning != true) {
451 longPressEffectAnimator =
452 ValueAnimator.ofFloat(0f, 1f).apply {
453 this.duration = longPressEffect?.effectDuration?.toLong() ?: 0L
454 interpolator = AccelerateDecelerateInterpolator()
455
456 doOnStart { longPressEffect?.handleAnimationStart() }
457 addUpdateListener {
458 val value = animatedValue as Float
459 if (value == 0f) {
460 bringToFront()
461 } else {
462 updateLongPressEffectProperties(value)
463 }
464 }
465 doOnEnd { longPressEffect?.handleAnimationComplete() }
466 doOnCancel { longPressEffect?.handleAnimationCancel() }
467 start()
468 }
469 }
470 }
471
472 override fun onReverseAnimator(playHaptics: Boolean) {
473 longPressEffectAnimator?.let {
474 val pausedProgress = it.animatedFraction
475 if (playHaptics) longPressEffect?.playReverseHaptics(pausedProgress)
476 it.reverse()
477 }
478 }
479
480 override fun onCancelAnimator() {
481 resetLongPressEffectProperties()
482 longPressEffectAnimator?.cancel()
483 }
484 }
485 }
486
487 private fun init(click: OnClickListener?, longClick: OnLongClickListener?) {
488 setOnClickListener(click)
489 onLongClickListener = longClick
490 }
491
492 override fun onStateChanged(state: QSTile.State) {
493 // We cannot use the handler here because sometimes, the views are not attached (if they
494 // are in a page that the ViewPager hasn't attached). Instead, we use a runnable where
495 // all its instances are `equal` to each other, so they can be used to remove them from the
496 // queue.
497 // This means that at any given time there's at most one enqueued runnable to change state.
498 // However, as we only ever care about the last state posted, this is fine.
499 val runnable = StateChangeRunnable(state.copy())
500 removeCallbacks(runnable)
501 post(runnable)
502 }
503
504 override fun getDetailY(): Int {
505 return top + height / 2
506 }
507
508 override fun hasOverlappingRendering(): Boolean {
509 // Avoid layers for this layout - we don't need them.
510 return false
511 }
512
513 override fun setClickable(clickable: Boolean) {
514 super.setClickable(clickable)
515 if (!Flags.qsTileFocusState()) {
516 background =
517 if (clickable && showRippleEffect) {
518 qsTileBackground.also {
519 // In case that the colorBackgroundDrawable was used as the background, make
520 // sure
521 // it has the correct callback instead of null
522 backgroundDrawable.callback = it
523 }
524 } else {
525 backgroundDrawable
526 }
527 }
528 }
529
530 override fun getLabelContainer(): View {
531 return labelContainer
532 }
533
534 override fun getLabel(): View {
535 return label
536 }
537
538 override fun getSecondaryLabel(): View {
539 return secondaryLabel
540 }
541
542 override fun getSecondaryIcon(): View {
543 return sideView
544 }
545
546 override fun setShouldBlockVisibilityChanges(block: Boolean) {
547 launchableViewDelegate.setShouldBlockVisibilityChanges(block)
548 }
549
550 override fun setVisibility(visibility: Int) {
551 launchableViewDelegate.setVisibility(visibility)
552 }
553
554 // Accessibility
555
556 override fun onInitializeAccessibilityEvent(event: AccessibilityEvent) {
557 super.onInitializeAccessibilityEvent(event)
558 if (!TextUtils.isEmpty(accessibilityClass)) {
559 event.className = accessibilityClass
560 }
561 if (
562 event.contentChangeTypes == AccessibilityEvent.CONTENT_CHANGE_TYPE_STATE_DESCRIPTION &&
563 stateDescriptionDeltas != null
564 ) {
565 event.text.add(stateDescriptionDeltas)
566 stateDescriptionDeltas = null
567 }
568 }
569
570 override fun onInitializeAccessibilityNodeInfo(info: AccessibilityNodeInfo) {
571 super.onInitializeAccessibilityNodeInfo(info)
572 // Clear selected state so it is not announce by talkback.
573 info.isSelected = false
574 info.text =
575 if (TextUtils.isEmpty(secondaryLabel.text)) {
576 "${label.text}"
577 } else {
578 "${label.text}, ${secondaryLabel.text}"
579 }
580 if (lastDisabledByPolicy) {
581 info.addAction(
582 AccessibilityNodeInfo.AccessibilityAction(
583 AccessibilityNodeInfo.AccessibilityAction.ACTION_CLICK.id,
584 resources.getString(
585 R.string.accessibility_tile_disabled_by_policy_action_description
586 ),
587 )
588 )
589 } else {
590 if (isLongClickable) {
591 info.addAction(
592 AccessibilityNodeInfo.AccessibilityAction(
593 AccessibilityNodeInfo.AccessibilityAction.ACTION_LONG_CLICK.id,
594 resources.getString(R.string.accessibility_long_click_tile),
595 )
596 )
597 }
598 }
599 if (!TextUtils.isEmpty(accessibilityClass)) {
600 info.className =
601 if (lastDisabledByPolicy) {
602 Button::class.java.name
603 } else {
604 accessibilityClass
605 }
606 if (Switch::class.java.name == accessibilityClass) {
607 info.isChecked = tileState
608 info.isCheckable = true
609 }
610 }
611 if (position != INVALID) {
612 info.collectionItemInfo =
613 AccessibilityNodeInfo.CollectionItemInfo(position, 1, 0, 1, false)
614 }
615 }
616
617 override fun toString(): String {
618 val sb = StringBuilder(javaClass.simpleName).append('[')
619 sb.append("locInScreen=(${locInScreen[0]}, ${locInScreen[1]})")
620 sb.append(", iconView=$icon")
621 sb.append(", tileState=$tileState")
622 sb.append("]")
623 return sb.toString()
624 }
625
626 @SuppressLint("ClickableViewAccessibility")
627 override fun onTouchEvent(event: MotionEvent?): Boolean {
628 // let the View run the onTouch logic for click and long-click detection
629 val result = super.onTouchEvent(event)
630 if (longPressEffect != null) {
631 when (event?.actionMasked) {
632 MotionEvent.ACTION_DOWN -> {
633 longPressEffect.handleActionDown()
634 if (isLongClickable) {
635 postDelayed(
636 { longPressEffect.handleTimeoutComplete() },
637 ViewConfiguration.getTapTimeout().toLong(),
638 )
639 }
640 }
641 MotionEvent.ACTION_UP -> longPressEffect.handleActionUp()
642 MotionEvent.ACTION_CANCEL -> longPressEffect.handleActionCancel()
643 }
644 }
645 return result
646 }
647
648 // HANDLE STATE CHANGES RELATED METHODS
649 protected open fun handleStateChanged(state: QSTile.State) {
650 val allowAnimations = animationsEnabled()
651 isClickable = state.state != Tile.STATE_UNAVAILABLE
652 isLongClickable = state.handlesLongClick
653 icon.setIcon(state, allowAnimations)
654 contentDescription = state.contentDescription
655
656 // State handling and description
657 val stateDescription = StringBuilder()
658 val arrayResId = SubtitleArrayMapping.getSubtitleId(state.spec)
659 val stateText = state.getStateText(arrayResId, resources)
660 state.secondaryLabel = state.getSecondaryLabel(stateText)
661 if (!TextUtils.isEmpty(stateText)) {
662 stateDescription.append(stateText)
663 }
664 if (state.disabledByPolicy && state.state != Tile.STATE_UNAVAILABLE) {
665 stateDescription.append(", ")
666 stateDescription.append(getUnavailableText(state.spec))
667 }
668 if (!TextUtils.isEmpty(state.stateDescription)) {
669 stateDescription.append(", ")
670 stateDescription.append(state.stateDescription)
671 if (
672 lastState != INVALID &&
673 state.state == lastState &&
674 state.stateDescription != lastStateDescription
675 ) {
676 stateDescriptionDeltas = state.stateDescription
677 }
678 }
679
680 setStateDescription(stateDescription.toString())
681 lastStateDescription = state.stateDescription
682
683 accessibilityClass =
684 if (state.state == Tile.STATE_UNAVAILABLE) {
685 null
686 } else {
687 state.expandedAccessibilityClassName
688 }
689
690 if (state is AdapterState) {
691 val newState = state.value
692 if (tileState != newState) {
693 tileState = newState
694 }
695 }
696
697 // Labels
698 if (!Objects.equals(label.text, state.label)) {
699 label.text = state.label
700 }
701 if (!Objects.equals(secondaryLabel.text, state.secondaryLabel)) {
702 secondaryLabel.text = state.secondaryLabel
703 secondaryLabel.visibility =
704 if (TextUtils.isEmpty(state.secondaryLabel)) {
705 GONE
706 } else {
707 VISIBLE
708 }
709 }
710
711 // Colors
712 if (state.state != lastState || state.disabledByPolicy != lastDisabledByPolicy) {
713 singleAnimator.cancel()
714 mQsLogger?.logTileBackgroundColorUpdateIfInternetTile(
715 state.spec,
716 state.state,
717 state.disabledByPolicy,
718 getBackgroundColorForState(state.state, state.disabledByPolicy),
719 )
720 if (allowAnimations) {
721 singleAnimator.setValues(
722 colorValuesHolder(
723 BACKGROUND_NAME,
724 backgroundColor,
725 getBackgroundColorForState(state.state, state.disabledByPolicy),
726 ),
727 colorValuesHolder(
728 LABEL_NAME,
729 label.currentTextColor,
730 getLabelColorForState(state.state, state.disabledByPolicy),
731 ),
732 colorValuesHolder(
733 SECONDARY_LABEL_NAME,
734 secondaryLabel.currentTextColor,
735 getSecondaryLabelColorForState(state.state, state.disabledByPolicy),
736 ),
737 colorValuesHolder(
738 CHEVRON_NAME,
739 chevronView.imageTintList?.defaultColor ?: 0,
740 getChevronColorForState(state.state, state.disabledByPolicy),
741 ),
742 colorValuesHolder(
743 OVERLAY_NAME,
744 backgroundOverlayColor,
745 getOverlayColorForState(state.state),
746 ),
747 )
748 singleAnimator.start()
749 } else {
750 setAllColors(
751 getBackgroundColorForState(state.state, state.disabledByPolicy),
752 getLabelColorForState(state.state, state.disabledByPolicy),
753 getSecondaryLabelColorForState(state.state, state.disabledByPolicy),
754 getChevronColorForState(state.state, state.disabledByPolicy),
755 getOverlayColorForState(state.state),
756 )
757 }
758 }
759
760 // Right side icon
761 loadSideViewDrawableIfNecessary(state)
762
763 label.isEnabled = !state.disabledByPolicy
764
765 lastState = state.state
766 lastDisabledByPolicy = state.disabledByPolicy
767 lastIconTint = icon.getColor(state)
768
769 // Long-press effects
770 updateLongPressEffect(state.handlesLongClick)
771 }
772
773 private fun updateLongPressEffect(handlesLongClick: Boolean) {
774 // The long press effect in the tile can't be updated if it is still running
775 if (
776 longPressEffect?.state != QSLongPressEffect.State.IDLE &&
777 longPressEffect?.state != QSLongPressEffect.State.CLICKED
778 )
779 return
780
781 longPressEffect.qsTile?.state?.handlesLongClick = handlesLongClick
782 if (handlesLongClick && longPressEffect.initializeEffect(longPressEffectDuration)) {
783 showRippleEffect = false
784 longPressEffect.qsTile?.state?.state = lastState // Store the tile's state
785 longPressEffect.resetState()
786 initializeLongPressProperties(measuredHeight, measuredWidth)
787 } else {
788 // Long-press effects might have been enabled before but the new state does not
789 // handle a long-press. In this case, we go back to the behaviour of a regular tile
790 // and clean-up the resources
791 showRippleEffect = isClickable
792 initialLongPressProperties = null
793 finalLongPressProperties = null
794 }
795 }
796
797 private fun setAllColors(
798 backgroundColor: Int,
799 labelColor: Int,
800 secondaryLabelColor: Int,
801 chevronColor: Int,
802 overlayColor: Int,
803 ) {
804 setColor(backgroundColor)
805 setLabelColor(labelColor)
806 setSecondaryLabelColor(secondaryLabelColor)
807 setChevronColor(chevronColor)
808 setOverlayColor(overlayColor)
809 }
810
811 private fun setColor(color: Int) {
812 backgroundBaseDrawable.mutate().setTint(color)
813 backgroundColor = color
814 }
815
816 private fun setLabelColor(color: Int) {
817 label.setTextColor(color)
818 }
819
820 private fun setSecondaryLabelColor(color: Int) {
821 secondaryLabel.setTextColor(color)
822 }
823
824 private fun setChevronColor(color: Int) {
825 chevronView.imageTintList = ColorStateList.valueOf(color)
826 }
827
828 private fun setOverlayColor(overlayColor: Int) {
829 backgroundOverlayDrawable.setTint(overlayColor)
830 backgroundOverlayColor = overlayColor
831 }
832
833 private fun loadSideViewDrawableIfNecessary(state: QSTile.State) {
834 if (state.sideViewCustomDrawable != null) {
835 customDrawableView.setImageDrawable(state.sideViewCustomDrawable)
836 customDrawableView.visibility = VISIBLE
837 chevronView.visibility = GONE
838 } else if (state !is AdapterState || state.forceExpandIcon) {
839 customDrawableView.setImageDrawable(null)
840 customDrawableView.visibility = GONE
841 chevronView.visibility = VISIBLE
842 } else {
843 customDrawableView.setImageDrawable(null)
844 customDrawableView.visibility = GONE
845 chevronView.visibility = GONE
846 }
847 }
848
849 private fun getUnavailableText(spec: String?): String {
850 val arrayResId = SubtitleArrayMapping.getSubtitleId(spec)
851 return resources.getStringArray(arrayResId)[Tile.STATE_UNAVAILABLE]
852 }
853
854 /*
855 * The view should not be animated if it's not on screen and no part of it is visible.
856 */
857 protected open fun animationsEnabled(): Boolean {
858 if (!isShown) {
859 return false
860 }
861 if (alpha != 1f) {
862 return false
863 }
864 getLocationOnScreen(locInScreen)
865 return locInScreen.get(1) >= -height
866 }
867
868 private fun getBackgroundColorForState(state: Int, disabledByPolicy: Boolean = false): Int {
869 return when {
870 state == Tile.STATE_UNAVAILABLE || disabledByPolicy -> colorUnavailable
871 state == Tile.STATE_ACTIVE -> colorActive
872 state == Tile.STATE_INACTIVE -> colorInactive
873 else -> {
874 Log.e(TAG, "Invalid state $state")
875 0
876 }
877 }
878 }
879
880 private fun getLabelColorForState(state: Int, disabledByPolicy: Boolean = false): Int {
881 return when {
882 state == Tile.STATE_UNAVAILABLE || disabledByPolicy -> colorLabelUnavailable
883 state == Tile.STATE_ACTIVE -> colorLabelActive
884 state == Tile.STATE_INACTIVE -> colorLabelInactive
885 else -> {
886 Log.e(TAG, "Invalid state $state")
887 0
888 }
889 }
890 }
891
892 private fun getSecondaryLabelColorForState(state: Int, disabledByPolicy: Boolean = false): Int {
893 return when {
894 state == Tile.STATE_UNAVAILABLE || disabledByPolicy -> colorSecondaryLabelUnavailable
895 state == Tile.STATE_ACTIVE -> colorSecondaryLabelActive
896 state == Tile.STATE_INACTIVE -> colorSecondaryLabelInactive
897 else -> {
898 Log.e(TAG, "Invalid state $state")
899 0
900 }
901 }
902 }
903
904 private fun getChevronColorForState(state: Int, disabledByPolicy: Boolean = false): Int =
905 getSecondaryLabelColorForState(state, disabledByPolicy)
906
907 private fun getOverlayColorForState(state: Int): Int {
908 return when (state) {
909 Tile.STATE_ACTIVE -> overlayColorActive
910 Tile.STATE_INACTIVE -> overlayColorInactive
911 else -> Color.TRANSPARENT
912 }
913 }
914
915 override fun onActivityLaunchAnimationEnd() {
916 longPressEffect?.resetState()
917 if (longPressEffect != null && !haveLongPressPropertiesBeenReset) {
918 resetLongPressEffectProperties()
919 }
920 }
921
922 private fun prepareForLaunch() {
923 val startingHeight = initialLongPressProperties?.height?.toInt() ?: 0
924 val startingWidth = initialLongPressProperties?.width?.toInt() ?: 0
925 val deltaH = finalLongPressProperties?.height?.minus(startingHeight)?.toInt() ?: 0
926 val deltaW = finalLongPressProperties?.width?.minus(startingWidth)?.toInt() ?: 0
927 paddingForLaunch.left = -deltaW / 2
928 paddingForLaunch.top = -deltaH / 2
929 paddingForLaunch.right = deltaW / 2
930 paddingForLaunch.bottom = deltaH / 2
931 }
932
933 override fun getPaddingForLaunchAnimation(): Rect =
934 if (longPressEffect?.state == QSLongPressEffect.State.LONG_CLICKED) {
935 paddingForLaunch
936 } else {
937 EMPTY_RECT
938 }
939
940 fun updateLongPressEffectProperties(effectProgress: Float) {
941 if (!isLongClickable || longPressEffect == null) return
942
943 if (haveLongPressPropertiesBeenReset) haveLongPressPropertiesBeenReset = false
944
945 // Dimensions change
946 val newHeight =
947 interpolateFloat(
948 effectProgress,
949 initialLongPressProperties?.height ?: 0f,
950 finalLongPressProperties?.height ?: 0f,
951 )
952 .toInt()
953 val newWidth =
954 interpolateFloat(
955 effectProgress,
956 initialLongPressProperties?.width ?: 0f,
957 finalLongPressProperties?.width ?: 0f,
958 )
959 .toInt()
960
961 val startingHeight = initialLongPressProperties?.height?.toInt() ?: 0
962 val startingWidth = initialLongPressProperties?.width?.toInt() ?: 0
963 val deltaH = (newHeight - startingHeight) / 2
964 val deltaW = (newWidth - startingWidth) / 2
965
966 background.updateBounds(
967 left = -deltaW,
968 top = -deltaH,
969 right = newWidth - deltaW,
970 bottom = newHeight - deltaH,
971 )
972
973 // Radius change
974 val newRadius =
975 interpolateFloat(
976 effectProgress,
977 initialLongPressProperties?.cornerRadius ?: 0f,
978 finalLongPressProperties?.cornerRadius ?: 0f,
979 )
980 changeCornerRadius(newRadius)
981
982 // Color change
983 setAllColors(
984 colorEvaluator.evaluate(
985 effectProgress,
986 initialLongPressProperties?.backgroundColor ?: 0,
987 finalLongPressProperties?.backgroundColor ?: 0,
988 ) as Int,
989 colorEvaluator.evaluate(
990 effectProgress,
991 initialLongPressProperties?.labelColor ?: 0,
992 finalLongPressProperties?.labelColor ?: 0,
993 ) as Int,
994 colorEvaluator.evaluate(
995 effectProgress,
996 initialLongPressProperties?.secondaryLabelColor ?: 0,
997 finalLongPressProperties?.secondaryLabelColor ?: 0,
998 ) as Int,
999 colorEvaluator.evaluate(
1000 effectProgress,
1001 initialLongPressProperties?.chevronColor ?: 0,
1002 finalLongPressProperties?.chevronColor ?: 0,
1003 ) as Int,
1004 colorEvaluator.evaluate(
1005 effectProgress,
1006 initialLongPressProperties?.overlayColor ?: 0,
1007 finalLongPressProperties?.overlayColor ?: 0,
1008 ) as Int,
1009 )
1010 icon.setTint(
1011 icon.mIcon as ImageView,
1012 colorEvaluator.evaluate(
1013 effectProgress,
1014 initialLongPressProperties?.iconColor ?: 0,
1015 finalLongPressProperties?.iconColor ?: 0,
1016 ) as Int,
1017 )
1018 }
1019
1020 private fun interpolateFloat(fraction: Float, start: Float, end: Float): Float =
1021 start + fraction * (end - start)
1022
1023 fun resetLongPressEffectProperties() {
1024 background.updateBounds(
1025 left = 0,
1026 top = 0,
1027 right = initialLongPressProperties?.width?.toInt() ?: measuredWidth,
1028 bottom = initialLongPressProperties?.height?.toInt() ?: measuredHeight,
1029 )
1030 changeCornerRadius(resources.getDimensionPixelSize(R.dimen.qs_corner_radius).toFloat())
1031 setAllColors(
1032 getBackgroundColorForState(lastState, lastDisabledByPolicy),
1033 getLabelColorForState(lastState, lastDisabledByPolicy),
1034 getSecondaryLabelColorForState(lastState, lastDisabledByPolicy),
1035 getChevronColorForState(lastState, lastDisabledByPolicy),
1036 getOverlayColorForState(lastState),
1037 )
1038 icon.setTint(icon.mIcon as ImageView, lastIconTint)
1039 haveLongPressPropertiesBeenReset = true
1040 }
1041
1042 @VisibleForTesting
1043 fun initializeLongPressProperties(startingHeight: Int, startingWidth: Int) {
1044 initialLongPressProperties =
1045 QSLongPressProperties(
1046 height = startingHeight.toFloat(),
1047 width = startingWidth.toFloat(),
1048 resources.getDimensionPixelSize(R.dimen.qs_corner_radius).toFloat(),
1049 getBackgroundColorForState(lastState),
1050 getLabelColorForState(lastState),
1051 getSecondaryLabelColorForState(lastState),
1052 getChevronColorForState(lastState),
1053 getOverlayColorForState(lastState),
1054 lastIconTint,
1055 )
1056
1057 finalLongPressProperties =
1058 QSLongPressProperties(
1059 height = LONG_PRESS_EFFECT_HEIGHT_SCALE * startingHeight,
1060 width = LONG_PRESS_EFFECT_WIDTH_SCALE * startingWidth,
1061 resources.getDimensionPixelSize(R.dimen.qs_corner_radius).toFloat() - 20,
1062 getBackgroundColorForState(Tile.STATE_ACTIVE),
1063 getLabelColorForState(Tile.STATE_ACTIVE),
1064 getSecondaryLabelColorForState(Tile.STATE_ACTIVE),
1065 getChevronColorForState(Tile.STATE_ACTIVE),
1066 getOverlayColorForState(Tile.STATE_ACTIVE),
1067 Utils.getColorAttrDefaultColor(context, R.attr.onShadeActive),
1068 )
1069 prepareForLaunch()
1070 }
1071
1072 private fun changeCornerRadius(radius: Float) {
1073 for (i in 0 until backgroundDrawable.numberOfLayers) {
1074 val layer = backgroundDrawable.getDrawable(i)
1075 if (layer is GradientDrawable) {
1076 layer.cornerRadius = radius
1077 }
1078 }
1079 }
1080
1081 @VisibleForTesting
1082 internal fun getCurrentColors(): List<Int> =
1083 listOf(
1084 backgroundColor,
1085 label.currentTextColor,
1086 secondaryLabel.currentTextColor,
1087 chevronView.imageTintList?.defaultColor ?: 0,
1088 )
1089
1090 inner class StateChangeRunnable(private val state: QSTile.State) : Runnable {
1091 override fun run() {
1092 var traceTag = "QSTileViewImpl#handleStateChanged"
1093 if (!state.spec.isNullOrEmpty()) {
1094 traceTag += ":"
1095 traceTag += state.spec
1096 }
1097 traceSection(traceTag.take(Trace.MAX_SECTION_NAME_LEN)) { handleStateChanged(state) }
1098 }
1099
1100 // We want all instances of this runnable to be equal to each other, so they can be used to
1101 // remove previous instances from the Handler/RunQueue of this view
1102 override fun equals(other: Any?): Boolean {
1103 return other is StateChangeRunnable
1104 }
1105
1106 // This makes sure that all instances have the same hashcode (because they are `equal`)
1107 override fun hashCode(): Int {
1108 return StateChangeRunnable::class.hashCode()
1109 }
1110 }
1111 }
1112
constrainSquishinessnull1113 fun constrainSquishiness(squish: Float): Float {
1114 return 0.1f + squish * 0.9f
1115 }
1116
colorValuesHoldernull1117 private fun colorValuesHolder(name: String, vararg values: Int): PropertyValuesHolder {
1118 return PropertyValuesHolder.ofInt(name, *values).apply {
1119 setEvaluator(ArgbEvaluator.getInstance())
1120 }
1121 }
1122